Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] e6a4927f57 deps(deps): bump aiofiles from 23.2.1 to 25.1.0
Bumps [aiofiles](https://github.com/Tinche/aiofiles) from 23.2.1 to 25.1.0.
- [Release notes](https://github.com/Tinche/aiofiles/releases)
- [Changelog](https://github.com/Tinche/aiofiles/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Tinche/aiofiles/compare/v23.2.1...v25.1.0)

---
updated-dependencies:
- dependency-name: aiofiles
  dependency-version: 25.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 07:07:13 +00:00
161 changed files with 3082 additions and 18041 deletions
+5 -64
View File
@@ -67,14 +67,6 @@ REMNAWAVE_SECRET_KEY=
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Шаблон имени пользователя в панели Remnawave
# Доступные плейсхолдеры аналогичны описанию выше
# {full_name} — Имя, Фамилия из Telegram
# {username} — @логин из Telegram (c @)
# {username_clean} — логин из Telegram (без @)
# {telegram_id} — ID Telegram
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
@@ -85,8 +77,7 @@ REMNAWAVE_USER_DELETE_MODE=delete
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
TRIAL_SQUAD_UUID=
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
@@ -155,10 +146,6 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=10000
# Включить выбор количества устройств при покупке и продлении
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
@@ -180,8 +167,8 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
# Telegram Stars (работает автоматически)
TELEGRAM_STARS_ENABLED=false
TELEGRAM_STARS_RATE_RUB=1.79
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
@@ -189,8 +176,6 @@ TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -243,6 +228,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
@@ -254,13 +240,8 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
# Интервал (в минутах) между автоматическими проверками пополнений
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
PAYMENT_SERVICE_NAME=Интернет-сервис
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
@@ -275,8 +256,6 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
# Эти порты используются только при ручном запуске старого aiohttp сервера.
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
CRYPTOBOT_WEBHOOK_PORT=8081
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
@@ -331,22 +310,6 @@ PAL24_SBP_BUTTON_VISIBLE=true
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
PAL24_CARD_BUTTON_VISIBLE=true
# PLATEGA
PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID=
PLATEGA_SECRET=
PLATEGA_BASE_URL=https://app.platega.io
PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=10000
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
PLATEGA_WEBHOOK_PORT=8086
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
@@ -369,7 +332,6 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
@@ -421,8 +383,6 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MONITORING_ENABLED=true
MAINTENANCE_RETRY_ATTEMPTS=1
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
@@ -431,8 +391,6 @@ DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Часовой пояс
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
@@ -470,20 +428,3 @@ LOG_FILE=logs/bot.log
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling, webhook или both
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=*
WEB_API_DOCS_ENABLED=false
WEB_API_DEFAULT_TOKEN=
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
MINIAPP_STATIC_PATH=miniapp
-42
View File
@@ -1,42 +0,0 @@
---
name: Bug Bedolage
about: Создать баг-репорт «Бедолаге»
title: "[BUG] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем текущую проблему.
Указываем настройки из `.env` конфига (если таковые имеются):
```yml
```
## Шаги для воспроизведения
1.
2.
3.
### Фактический результат
*
*
*
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Ожидаемое поведение
Опишите, как должно работать.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
-32
View File
@@ -1,32 +0,0 @@
---
name: Feat Bedolage
about: Создать запрос на фичу «Бедолаге»
title: "[FEAT] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем что хотим.
Указываем настройки из `.env` конфига (если таковые к месту):
```yml
```
## Текущее поведение
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Желаемое поведение
Опишите, как хотелось бы, чтобы было.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-$(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="v2.6.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
-1
View File
@@ -1,6 +1,5 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# Исключения: разрешаем только нужные файлы
!.dockerignore
+5 -5
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim AS builder
FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
@@ -12,9 +12,9 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.14-slim
FROM python:3.13-slim
ARG VERSION="v2.6.2"
ARG VERSION="v2.5.6"
ARG BUILD_DATE
ARG VCS_REF
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8080
EXPOSE 8080 8081 8082
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.vendor="fr1ngg"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
CMD ["python", "main.py"]
-39
View File
@@ -1,39 +0,0 @@
.PHONY: up
up: ## Поднять контейнеры (detached)
@echo "🚀 Поднимаем контейнеры (detached)..."
docker compose up -d --build
.PHONY: up-follow
up-follow: ## Поднять контейнеры с логами
@echo "📡 Поднимаем контейнеры (в консоли)..."
docker compose up --build
.PHONY: down
down: ## Остановить и удалить контейнеры
@echo "🛑 Останавливаем и удаляем контейнеры..."
docker compose down
.PHONY: reload
reload: ## Перезапустить контейнеры (detached)
@$(MAKE) down
@$(MAKE) up
.PHONY: reload-follow
reload-follow: ## Перезапустить контейнеры с логами
@$(MAKE) down
@$(MAKE) up-follow
.PHONY: test
test: ## Запустить тесты
@echo "🧪 Запускаем тесты..."
pytest -v
.PHONY: help
help: ## Показать список доступных команд
@echo ""
@echo "📘 Команды Makefile:"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
sed -E 's/:.*?## /| /' | \
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@echo ""
+495 -338
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -136,7 +136,8 @@ logger.debug(f"Webhook payload: {webhook_data}")
# Открыть только необходимые порты
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
ufw deny 8082/tcp
```
### 📊 Мониторинг безопасности
@@ -238,6 +239,7 @@ REMNAWAVE_API_KEY=your_api_key
ADMIN_IDS=123456789,987654321
# Webhook секреты - генерируйте случайные значения
YOOKASSA_WEBHOOK_SECRET=random_secret_here
TRIBUTE_WEBHOOK_SECRET=another_random_secret
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
```
@@ -281,7 +283,7 @@ services:
# Не пробрасывайте порты наружу без необходимости
ports:
- "127.0.0.1:8080:8080" # Только localhost
- "127.0.0.1:8081:8081" # Только localhost
# Read-only root filesystem
read_only: true
+5 -8
View File
@@ -182,14 +182,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
logger.info("⚡ Зарегистрированы обработчики простой покупки")
logger.info("⚡ Зарегистрированы обработчики простой подписки")
if settings.is_maintenance_monitoring_enabled():
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
else:
logger.info("Мониторинг техработ отключен настройками")
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
logger.info("Бот успешно настроен")
+38 -256
View File
@@ -7,7 +7,6 @@ import html
from collections import defaultdict
from datetime import time
from typing import List, Optional, Union, Dict
from zoneinfo import ZoneInfo
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
from pathlib import Path
@@ -61,8 +60,6 @@ class Settings(BaseSettings):
SQLITE_PATH: str = "./data/bot.db"
LOCALES_PATH: str = "./locales"
TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC"))
DATABASE_MODE: str = "auto"
@@ -76,7 +73,6 @@ class Settings(BaseSettings):
REMNAWAVE_PASSWORD: Optional[str] = None
REMNAWAVE_AUTH_TYPE: str = "api_key"
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}"
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
REMNAWAVE_AUTO_SYNC_ENABLED: bool = False
REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00"
@@ -85,10 +81,9 @@ class Settings(BaseSettings):
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
TRIAL_PAYMENT_ENABLED: bool = False
TRIAL_ACTIVATION_PRICE: int = 0
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
TRIAL_SQUAD_UUID: Optional[str] = None
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
RESET_TRAFFIC_ON_PAYMENT: bool = False
MAX_DEVICES_LIMIT: int = 20
@@ -128,12 +123,10 @@ class Settings(BaseSettings):
PRICE_TRAFFIC_500GB: int = 19000
PRICE_TRAFFIC_1000GB: int = 19500
PRICE_TRAFFIC_UNLIMITED: int = 20000
TRAFFIC_PACKAGES_CONFIG: str = ""
PRICE_PER_DEVICE: int = 5000
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
@@ -149,6 +142,7 @@ class Settings(BaseSettings):
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
REFERRED_USER_REWARD: int = 0
AUTOPAY_WARNING_DAYS: str = "3,1"
@@ -160,10 +154,8 @@ class Settings(BaseSettings):
INACTIVE_USER_DELETE_MONTHS: int = 3
MAINTENANCE_MODE: bool = False
MAINTENANCE_CHECK_INTERVAL: int = 30
MAINTENANCE_AUTO_ENABLE: bool = True
MAINTENANCE_MONITORING_ENABLED: bool = True
MAINTENANCE_RETRY_ATTEMPTS: int = 1
MAINTENANCE_CHECK_INTERVAL: int = 30
MAINTENANCE_AUTO_ENABLE: bool = True
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
TELEGRAM_STARS_ENABLED: bool = True
@@ -188,7 +180,7 @@ class Settings(BaseSettings):
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
@@ -271,20 +263,6 @@ class Settings(BaseSettings):
PAL24_SBP_BUTTON_VISIBLE: bool = True
PAL24_CARD_BUTTON_VISIBLE: bool = True
PLATEGA_ENABLED: bool = False
PLATEGA_MERCHANT_ID: Optional[str] = None
PLATEGA_SECRET: Optional[str] = None
PLATEGA_BASE_URL: str = "https://app.platega.io"
PLATEGA_RETURN_URL: Optional[str] = None
PLATEGA_FAILED_URL: Optional[str] = None
PLATEGA_CURRENCY: str = "RUB"
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
PLATEGA_WEBHOOK_PORT: int = 8086
WATA_ENABLED: bool = False
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
WATA_ACCESS_TOKEN: Optional[str] = None
@@ -306,7 +284,6 @@ class Settings(BaseSettings):
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
MINIAPP_STATIC_PATH: str = "miniapp"
MINIAPP_PURCHASE_URL: str = ""
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
@@ -335,13 +312,6 @@ class Settings(BaseSettings):
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
WEBHOOK_SECRET_TOKEN: Optional[str] = None
WEBHOOK_DROP_PENDING_UPDATES: bool = True
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
WEBHOOK_WORKERS: int = 4
WEBHOOK_ENQUEUE_TIMEOUT: float = 0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT: float = 30.0
BOT_RUN_MODE: str = "polling"
WEB_API_ENABLED: bool = False
WEB_API_HOST: str = "0.0.0.0"
@@ -580,34 +550,6 @@ class Settings(BaseSettings):
description = re.sub(r'\s+', ' ', description).strip()
return description
def format_remnawave_username(
self,
*,
full_name: str,
username: Optional[str],
telegram_id: int
) -> str:
template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or "user_{telegram_id}"
username_clean = (username or "").lstrip("@")
full_name_value = full_name or ""
values = defaultdict(str, {
"full_name": full_name_value,
"username": username_clean,
"username_clean": username_clean,
"telegram_id": str(telegram_id),
})
raw_username = template.format_map(values).strip()
sanitized_username = re.sub(r"[^0-9A-Za-z._-]+", "_", raw_username)
sanitized_username = re.sub(r"_+", "_", sanitized_username).strip("._-")
if not sanitized_username:
sanitized_username = f"user_{telegram_id}"
return sanitized_username[:64]
@staticmethod
def parse_daily_time_list(raw_value: Optional[str]) -> List[time]:
if not raw_value:
@@ -855,53 +797,9 @@ class Settings(BaseSettings):
def is_traffic_fixed(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
def is_devices_selection_enabled(self) -> bool:
return self.DEVICES_SELECTION_ENABLED
def get_devices_selection_disabled_amount(self) -> Optional[int]:
raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT
if raw_value in (None, ""):
return None
try:
value = int(raw_value)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s",
raw_value,
)
return None
if value < 0:
return 0
return value
def get_disabled_mode_device_limit(self) -> Optional[int]:
return self.get_devices_selection_disabled_amount()
def is_trial_paid_activation_enabled(self) -> bool:
return bool(self.TRIAL_PAYMENT_ENABLED)
def get_trial_activation_price(self) -> int:
try:
value = int(self.TRIAL_ACTIVATION_PRICE)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение TRIAL_ACTIVATION_PRICE: %s",
self.TRIAL_ACTIVATION_PRICE,
)
return 0
if value < 0:
return 0
return value
def is_yookassa_enabled(self) -> bool:
return (self.YOOKASSA_ENABLED and
@@ -950,74 +848,6 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def is_platega_enabled(self) -> bool:
return (
self.PLATEGA_ENABLED
and self.PLATEGA_MERCHANT_ID is not None
and self.PLATEGA_SECRET is not None
)
def get_platega_return_url(self) -> Optional[str]:
if self.PLATEGA_RETURN_URL:
return self.PLATEGA_RETURN_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-success"
return None
def get_platega_failed_url(self) -> Optional[str]:
if self.PLATEGA_FAILED_URL:
return self.PLATEGA_FAILED_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-failed"
return None
def get_platega_active_methods(self) -> List[int]:
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
normalized = raw_value.replace(";", ",")
methods: list[int] = []
seen: set[int] = set()
for part in normalized.split(","):
part = part.strip()
if not part:
continue
try:
method_code = int(part)
except ValueError:
logger.warning("Некорректный код метода Platega: %s", part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
seen.add(method_code)
if not methods:
return [2]
return methods
@staticmethod
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
return {
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
}
def get_platega_method_display_name(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if info and info.get("name"):
return info["name"]
return f"Метод {method_code}"
def get_platega_method_display_title(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if not info:
return f"Platega {method_code}"
return info.get("title") or info.get("name") or f"Platega {method_code}"
def is_wata_enabled(self) -> bool:
return (
self.WATA_ENABLED
@@ -1124,13 +954,6 @@ class Settings(BaseSettings):
def get_maintenance_check_interval(self) -> int:
return self.MAINTENANCE_CHECK_INTERVAL
def get_maintenance_retry_attempts(self) -> int:
try:
attempts = int(self.MAINTENANCE_RETRY_ATTEMPTS)
except (TypeError, ValueError):
attempts = 1
return max(1, attempts)
def is_base_promo_group_period_discount_enabled(self) -> bool:
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
@@ -1173,9 +996,6 @@ class Settings(BaseSettings):
def is_maintenance_auto_enable(self) -> bool:
return self.MAINTENANCE_AUTO_ENABLE
def is_maintenance_monitoring_enabled(self) -> bool:
return self.MAINTENANCE_MONITORING_ENABLED
def get_available_subscription_periods(self) -> List[int]:
try:
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
@@ -1267,7 +1087,34 @@ class Settings(BaseSettings):
return (self.BACKUP_SEND_ENABLED and
self.get_backup_send_chat_id() is not None)
def get_referred_user_reward_kopeks(self) -> int:
"""Return the referred user reward normalized to kopeks.
Historically the value was stored in kopeks, however some
installations provide it in rubles. To keep backward compatibility we
treat any value greater than or equal to one thousand as already being
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
in rubles and therefore converted to kopeks.
"""
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
try:
value = int(raw_value)
except (TypeError, ValueError):
return 0
if value <= 0:
return 0
if value >= 1000:
return value
return value * 100
def get_referral_settings(self) -> Dict:
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
return {
"program_enabled": self.is_referral_program_enabled(),
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
@@ -1275,6 +1122,8 @@ class Settings(BaseSettings):
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
"referred_user_reward": referred_reward_kopeks,
"referred_user_reward_kopeks": referred_reward_kopeks,
}
def is_referral_program_enabled(self) -> bool:
@@ -1532,82 +1381,15 @@ class Settings(BaseSettings):
def is_support_contact_enabled(self) -> bool:
return self.get_support_system_mode() in {"contact", "both"}
def get_bot_run_mode(self) -> str:
mode = (self.BOT_RUN_MODE or "polling").strip().lower()
if mode not in {"polling", "webhook", "both"}:
return "polling"
return mode
def get_telegram_webhook_path(self) -> str:
raw_path = (self.WEBHOOK_PATH or "/webhook").strip()
if not raw_path:
raw_path = "/webhook"
if not raw_path.startswith("/"):
raw_path = "/" + raw_path
return raw_path
def get_webhook_queue_maxsize(self) -> int:
try:
size = int(self.WEBHOOK_MAX_QUEUE_SIZE)
except (TypeError, ValueError):
size = 1024
return max(1, size)
def get_webhook_worker_count(self) -> int:
try:
workers = int(self.WEBHOOK_WORKERS)
except (TypeError, ValueError):
workers = 1
return max(1, workers)
def get_webhook_enqueue_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_ENQUEUE_TIMEOUT)
except (TypeError, ValueError):
timeout = 0.0
return max(0.0, timeout)
def get_webhook_shutdown_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT)
except (TypeError, ValueError):
timeout = 30.0
return max(1.0, timeout)
def get_telegram_webhook_url(self) -> Optional[str]:
base_url = (self.WEBHOOK_URL or "").strip()
if not base_url:
return None
path = self.get_telegram_webhook_path()
return f"{base_url.rstrip('/')}{path}"
def get_miniapp_static_path(self) -> Path:
raw_path = (self.MINIAPP_STATIC_PATH or "miniapp").strip()
if not raw_path:
raw_path = "miniapp"
return Path(raw_path)
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"extra": "ignore"
"extra": "ignore"
}
@field_validator("TIMEZONE")
@classmethod
def validate_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except Exception as exc: # pragma: no cover - defensive validation
raise ValueError(
f"Некорректный идентификатор часового пояса: {value}"
) from exc
return value
settings = Settings()
ENV_OVERRIDE_KEYS = set(settings.model_fields_set)
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
14: "PRICE_14_DAYS",
-23
View File
@@ -1,23 +0,0 @@
"""Database package exports."""
from .database import (
DatabaseManager,
batch_ops,
close_db,
db_manager,
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
)
__all__ = [
"DatabaseManager",
"batch_ops",
"close_db",
"db_manager",
"get_db",
"get_db_read_only",
"get_pool_metrics",
"init_db",
]
-156
View File
@@ -1,156 +0,0 @@
"""CRUD-операции для платежей Platega."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PlategaPayment
logger = logging.getLogger(__name__)
async def create_platega_payment(
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
payment_method_code: int,
correlation_id: str,
platega_transaction_id: Optional[str],
redirect_url: Optional[str],
return_url: Optional[str],
failed_url: Optional[str],
payload: Optional[str],
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
payment = PlategaPayment(
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=platega_transaction_id,
redirect_url=redirect_url,
return_url=return_url,
failed_url=failed_url,
payload=payload,
metadata_json=metadata or {},
expires_at=expires_at,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
payment.id,
platega_transaction_id,
amount_kopeks,
user_id,
)
return payment
async def get_platega_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(PlategaPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_id_for_update(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment)
.where(PlategaPayment.id == payment_id)
.with_for_update()
)
return result.scalar_one_or_none()
async def get_platega_payment_by_transaction_id(
db: AsyncSession, transaction_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.platega_transaction_id == transaction_id
)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_correlation_id(
db: AsyncSession, correlation_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.correlation_id == correlation_id
)
)
return result.scalar_one_or_none()
async def update_platega_payment(
db: AsyncSession,
*,
payment: PlategaPayment,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
platega_transaction_id: Optional[str] = None,
redirect_url: Optional[str] = None,
callback_payload: Optional[dict[str, Any]] = None,
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
if status is not None:
payment.status = status
if is_paid is not None:
payment.is_paid = is_paid
if paid_at is not None:
payment.paid_at = paid_at
if platega_transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = platega_transaction_id
if redirect_url is not None:
payment.redirect_url = redirect_url
if callback_payload is not None:
payment.callback_payload = callback_payload
if metadata is not None:
payment.metadata_json = metadata
if expires_at is not None:
payment.expires_at = expires_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_platega_payment_to_transaction(
db: AsyncSession,
*,
payment: PlategaPayment,
transaction_id: int,
) -> PlategaPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
+3 -39
View File
@@ -1,11 +1,11 @@
import logging
from typing import Dict, List, Optional, Set, Tuple
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User, UserPromoGroup
from app.database.models import PromoGroup, User
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
@@ -38,7 +38,7 @@ async def get_promo_groups_with_counts(
select(PromoGroup, func.count(User.id))
.outerjoin(User, User.promo_group_id == PromoGroup.id)
.group_by(PromoGroup.id)
.order_by(PromoGroup.priority.desc(), PromoGroup.name)
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
)
if offset:
@@ -89,7 +89,6 @@ async def create_promo_group(
db: AsyncSession,
name: str,
*,
priority: int = 0,
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
@@ -111,7 +110,6 @@ async def create_promo_group(
promo_group = PromoGroup(
name=name.strip(),
priority=max(0, priority),
server_discount_percent=max(0, min(100, server_discount_percent)),
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
device_discount_percent=max(0, min(100, device_discount_percent)),
@@ -154,7 +152,6 @@ async def update_promo_group(
group: PromoGroup,
*,
name: Optional[str] = None,
priority: Optional[int] = None,
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
@@ -165,8 +162,6 @@ async def update_promo_group(
) -> PromoGroup:
if name is not None:
group.name = name.strip()
if priority is not None:
group.priority = max(0, priority)
if server_discount_percent is not None:
group.server_discount_percent = max(0, min(100, server_discount_percent))
if traffic_discount_percent is not None:
@@ -233,42 +228,11 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
return False
# Получаем список пользователей, связанных с удаляемой промогруппой
affected_user_ids: Set[int] = set()
user_ids_result = await db.execute(
select(User.id).where(User.promo_group_id == group.id)
)
affected_user_ids.update(user_ids_result.scalars().all())
promo_group_links_result = await db.execute(
select(UserPromoGroup.user_id).where(UserPromoGroup.promo_group_id == group.id)
)
affected_user_ids.update(promo_group_links_result.scalars().all())
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
.values(promo_group_id=default_group.id)
)
if affected_user_ids:
existing_defaults_result = await db.execute(
select(UserPromoGroup.user_id)
.where(UserPromoGroup.promo_group_id == default_group.id)
.where(UserPromoGroup.user_id.in_(affected_user_ids))
)
existing_default_user_ids = set(existing_defaults_result.scalars().all())
for user_id in affected_user_ids - existing_default_user_ids:
db.add(
UserPromoGroup(
user_id=user_id,
promo_group_id=default_group.id,
assigned_by="system",
)
)
await db.delete(group)
await db.commit()
+12 -58
View File
@@ -13,50 +13,12 @@ logger = logging.getLogger(__name__)
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
result = await db.execute(
select(PromoCode)
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.options(selectinload(PromoCode.uses))
.where(PromoCode.code == code.upper())
)
return result.scalar_one_or_none()
async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]:
"""
Получает промокод по ID с eager loading всех связанных данных.
Используется для избежания lazy loading в async контексте.
"""
result = await db.execute(
select(PromoCode)
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.where(PromoCode.id == promo_id)
)
return result.scalar_one_or_none()
async def check_promocode_validity(db: AsyncSession, code: str) -> dict:
"""
Проверяет существование и валидность промокода без активации.
Возвращает словарь с информацией о промокоде.
"""
promocode = await get_promocode_by_code(db, code)
if not promocode:
return {"valid": False, "error": "not_found", "promocode": None}
if not promocode.is_valid:
if promocode.current_uses >= promocode.max_uses:
return {"valid": False, "error": "used", "promocode": None}
else:
return {"valid": False, "error": "expired", "promocode": None}
return {"valid": True, "error": None, "promocode": promocode}
async def create_promocode(
db: AsyncSession,
code: str,
@@ -65,8 +27,7 @@ async def create_promocode(
subscription_days: int = 0,
max_uses: int = 1,
valid_until: Optional[datetime] = None,
created_by: Optional[int] = None,
promo_group_id: Optional[int] = None
created_by: Optional[int] = None
) -> PromoCode:
promocode = PromoCode(
@@ -76,18 +37,14 @@ async def create_promocode(
subscription_days=subscription_days,
max_uses=max_uses,
valid_until=valid_until,
created_by=created_by,
promo_group_id=promo_group_id
created_by=created_by
)
db.add(promocode)
await db.commit()
await db.refresh(promocode)
if promo_group_id:
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
else:
logger.info(f"✅ Создан промокод: {code}")
logger.info(f"✅ Создан промокод: {code}")
return promocode
@@ -96,9 +53,9 @@ async def use_promocode(
promocode_id: int,
user_id: int
) -> bool:
try:
promocode = await get_promocode_by_id(db, promocode_id)
promocode = await db.get(PromoCode, promocode_id)
if not promocode:
return False
@@ -186,17 +143,14 @@ async def get_promocodes_list(
limit: int = 50,
is_active: Optional[bool] = None
) -> List[PromoCode]:
query = select(PromoCode).options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
query = select(PromoCode).options(selectinload(PromoCode.uses))
if is_active is not None:
query = query.where(PromoCode.is_active == is_active)
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
+4 -12
View File
@@ -47,7 +47,6 @@ async def create_server_squad(
max_users: int = None,
is_available: bool = True,
is_trial_eligible: bool = False,
sort_order: int = 0,
promo_group_ids: Optional[Iterable[int]] = None,
) -> ServerSquad:
@@ -81,7 +80,6 @@ async def create_server_squad(
max_users=max_users,
is_available=is_available,
is_trial_eligible=is_trial_eligible,
sort_order=sort_order,
allowed_promo_groups=promo_groups,
)
@@ -262,15 +260,8 @@ async def update_server_squad(
) -> Optional[ServerSquad]:
valid_fields = {
"display_name",
"original_name",
"country_code",
"price_kopeks",
"description",
"max_users",
"is_available",
"sort_order",
"is_trial_eligible",
'display_name', 'country_code', 'price_kopeks', 'description',
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
@@ -515,6 +506,7 @@ async def choose_random_trial_server_squad(
async def get_random_trial_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
squad = await choose_random_trial_server_squad(db)
@@ -522,7 +514,7 @@ async def get_random_trial_squad_uuid(
if squad:
return squad.squad_uuid
return None
return fallback_uuid
def _generate_display_name(original_name: str) -> str:
+31 -101
View File
@@ -11,12 +11,10 @@ from app.database.models import (
User,
SubscriptionServer,
PromoGroup,
UserPromoGroup,
)
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -43,19 +41,21 @@ async def create_trial_subscription(
user_id: int,
duration_days: int = None,
traffic_limit_gb: int = None,
device_limit: Optional[int] = None,
device_limit: int = None,
squad_uuid: str = None
) -> Subscription:
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
if device_limit is None:
device_limit = settings.TRIAL_DEVICE_LIMIT
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
if not squad_uuid:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
squad_uuid = await get_random_trial_squad_uuid(db)
squad_uuid = await get_random_trial_squad_uuid(
db,
settings.TRIAL_SQUAD_UUID,
)
if squad_uuid:
logger.debug(
@@ -69,6 +69,7 @@ async def create_trial_subscription(
user_id,
error,
)
squad_uuid = settings.TRIAL_SQUAD_UUID
end_date = datetime.utcnow() + timedelta(days=duration_days)
@@ -125,16 +126,13 @@ async def create_paid_subscription(
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: Optional[int] = None,
device_limit: int = 1,
connected_squads: List[str] = None,
update_server_counters: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -192,27 +190,10 @@ async def extend_subscription(
days: int
) -> Subscription:
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
bonus_days = 0
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# Вычисляем остаток триала
if subscription.end_date and subscription.end_date > current_time:
remaining = subscription.end_date - current_time
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
logger.info(
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
bonus_days,
subscription.id,
)
# Применяем продление с учетом бонусных дней
total_days = days + bonus_days
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
@@ -221,15 +202,27 @@ async def extend_subscription(
subscription.end_date,
)
elif subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=total_days)
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=total_days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
if subscription.is_trial:
start_date = subscription.start_date or current_time
total_duration = subscription.end_date - start_date
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
if total_duration > max_trial_duration:
subscription.is_trial = False
logger.info(
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
", итоговая длительность: %s дней",
subscription.id,
total_duration.days,
)
if subscription.user:
subscription.user.has_had_paid_subscription = True
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
@@ -917,7 +910,7 @@ async def get_subscription_renewal_cost(
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(Subscription.user).selectinload(User.promo_group),
)
.where(Subscription.id == subscription_id)
)
@@ -1134,13 +1127,7 @@ async def check_and_update_subscription_status(
current_time = datetime.utcnow()
logger.info(
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
subscription.id,
subscription.status,
format_local_datetime(subscription.end_date),
format_local_datetime(current_time),
)
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
@@ -1157,63 +1144,6 @@ async def check_and_update_subscription_status(
return subscription
async def create_subscription_no_commit(
db: AsyncSession,
user_id: int,
status: str = "trial",
is_trial: bool = True,
end_date: datetime = None,
traffic_limit_gb: int = 10,
traffic_used_gb: float = 0.0,
device_limit: int = 1,
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = "",
subscription_crypto_link: str = "",
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
) -> Subscription:
"""
Создает подписку без немедленного коммита для пакетной обработки
"""
if end_date is None:
end_date = datetime.utcnow() + timedelta(days=3)
if connected_squads is None:
connected_squads = []
subscription = Subscription(
user_id=user_id,
status=status,
is_trial=is_trial,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
device_limit=device_limit,
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link,
autopay_enabled=(
settings.is_autopay_enabled_by_default()
if autopay_enabled is None
else autopay_enabled
),
autopay_days_before=(
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
if autopay_days_before is None
else autopay_days_before
),
)
db.add(subscription)
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
return subscription
async def create_subscription(
db: AsyncSession,
user_id: int,
+15 -165
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from typing import Optional, List, Dict
from sqlalchemy import select, and_, or_, func, case, nullslast, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy.orm import selectinload
from sqlalchemy.exc import IntegrityError
from app.database.models import (
@@ -15,7 +15,6 @@ from app.database.models import (
SubscriptionStatus,
Transaction,
PromoGroup,
UserPromoGroup,
PaymentMethod,
TransactionType,
)
@@ -39,16 +38,14 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
@@ -59,64 +56,26 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
if not username:
return None
normalized = username.lower()
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(func.lower(User.username) == normalized)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.options(selectinload(User.promo_group))
.where(User.referral_code == referral_code)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
return result.scalar_one_or_none()
async def create_unique_referral_code(db: AsyncSession) -> str:
@@ -163,57 +122,6 @@ async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
return default_group
async def create_user_no_commit(
db: AsyncSession,
telegram_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language: str = "ru",
referred_by_id: int = None,
referral_code: str = None
) -> User:
"""
Создает пользователя без немедленного коммита для пакетной обработки
"""
if not referral_code:
referral_code = await create_unique_referral_code(db)
default_group = await _get_or_create_default_promo_group(db)
promo_group_id = default_group.id
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(user)
# Обязательно выполняем flush, чтобы получить присвоенный первичный ключ
await db.flush()
# Сохраняем ссылку на группу, чтобы дальнейшие операции могли её использовать
user.promo_group = default_group
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(
f"✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)"
)
return user
async def create_user(
db: AsyncSession,
telegram_id: int,
@@ -592,11 +500,7 @@ async def get_users_list(
order_by_purchase_count: bool = False
) -> List[User]:
query = select(User).options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
query = select(User).options(selectinload(User.subscription))
if status:
query = query.where(User.status == status.value)
@@ -610,14 +514,7 @@ async def get_users_list(
]
if search.isdigit():
try:
search_int = int(search)
# Добавляем условие поиска по telegram_id, который является BigInteger
# и может содержать большие значения, в отличие от User.id (INTEGER)
conditions.append(User.telegram_id == search_int)
except ValueError:
# Если не удалось преобразовать в int, просто ищем по текстовым полям
pass
conditions.append(User.telegram_id == int(search))
query = query.where(or_(*conditions))
@@ -691,15 +588,7 @@ async def get_users_list(
query = query.offset(offset).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
return result.scalars().all()
async def get_users_count(
@@ -722,14 +611,7 @@ async def get_users_count(
]
if search.isdigit():
try:
search_int = int(search)
# Добавляем условие поиска по telegram_id, который является BigInteger
# и может содержать большие значения, в отличие от User.id (INTEGER)
conditions.append(User.telegram_id == search_int)
except ValueError:
# Если не удалось преобразовать в int, просто ищем по текстовым полям
pass
conditions.append(User.telegram_id == int(search))
query = query.where(or_(*conditions))
@@ -798,22 +680,12 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
)
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
return result.scalars().all()
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
@@ -821,11 +693,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
base_query = (
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.options(selectinload(User.subscription))
.where(User.status == UserStatus.ACTIVE.value)
)
@@ -870,15 +738,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
return []
result = await db.execute(query.order_by(User.id))
users = result.scalars().unique().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
return result.scalars().unique().all()
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
@@ -888,8 +748,6 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(
@@ -899,15 +757,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
)
)
)
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
return result.scalars().all()
async def delete_user(db: AsyncSession, user: User) -> bool:
-311
View File
@@ -1,311 +0,0 @@
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
import logging
from datetime import datetime
from typing import List, Optional
from sqlalchemy import select, and_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import UserPromoGroup, PromoGroup, User
logger = logging.getLogger(__name__)
async def _sync_user_primary_promo_group(
db: AsyncSession,
user_id: int,
) -> None:
"""Синхронизирует колонку users.promo_group_id с primary промогруппой."""
try:
result = await db.execute(
select(UserPromoGroup.promo_group_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.where(UserPromoGroup.user_id == user_id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
first = result.first()
new_primary_id = first[0] if first else None
user = await db.get(User, user_id)
if not user:
return
if user.promo_group_id != new_primary_id:
user.promo_group_id = new_primary_id
user.updated_at = datetime.utcnow()
except Exception as error:
logger.error(
"Ошибка синхронизации primary промогруппы пользователя %s: %s",
user_id,
error,
)
async def sync_user_primary_promo_group(
db: AsyncSession,
user_id: int,
) -> None:
"""Публичная обертка для синхронизации primary промогруппы пользователя."""
await _sync_user_primary_promo_group(db, user_id)
async def add_user_to_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int,
assigned_by: str = "admin"
) -> Optional[UserPromoGroup]:
"""
Добавляет пользователю промогруппу.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
Returns:
UserPromoGroup или None если уже существует
"""
try:
# Проверяем существование связи
existing = await has_user_promo_group(db, user_id, promo_group_id)
if existing:
logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}")
return None
# Создаем новую связь
user_promo_group = UserPromoGroup(
user_id=user_id,
promo_group_id=promo_group_id,
assigned_by=assigned_by,
)
db.add(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
await db.refresh(user_promo_group)
logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})")
return user_promo_group
except Exception as error:
logger.error(f"Ошибка добавления промогруппы пользователю: {error}")
await db.rollback()
return None
async def remove_user_from_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> bool:
"""
Удаляет промогруппу у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
Returns:
True если удалено, False если связи не было
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(
UserPromoGroup.user_id == user_id,
UserPromoGroup.promo_group_id == promo_group_id
)
)
)
user_promo_group = result.scalar_one_or_none()
if not user_promo_group:
logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена")
return False
await db.delete(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}")
return True
except Exception as error:
logger.error(f"Ошибка удаления промогруппы у пользователя: {error}")
await db.rollback()
return False
async def get_user_promo_groups(
db: AsyncSession,
user_id: int
) -> List[UserPromoGroup]:
"""
Получает все промогруппы пользователя, отсортированные по приоритету.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
"""
try:
result = await db.execute(
select(UserPromoGroup)
.options(selectinload(UserPromoGroup.promo_group))
.where(UserPromoGroup.user_id == user_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
return list(result.scalars().all())
except Exception as error:
logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}")
return []
async def get_primary_user_promo_group(
db: AsyncSession,
user_id: int
) -> Optional[PromoGroup]:
"""
Получает промогруппу пользователя с максимальным приоритетом.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
PromoGroup с максимальным приоритетом или None
"""
try:
user_promo_groups = await get_user_promo_groups(db, user_id)
if not user_promo_groups:
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
except Exception as error:
logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}")
return None
async def has_user_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> bool:
"""
Проверяет наличие промогруппы у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
Returns:
True если пользователь уже имеет эту промогруппу
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(
UserPromoGroup.user_id == user_id,
UserPromoGroup.promo_group_id == promo_group_id
)
)
)
return result.scalar_one_or_none() is not None
except Exception as error:
logger.error(f"Ошибка проверки промогруппы пользователя: {error}")
return False
async def count_user_promo_groups(
db: AsyncSession,
user_id: int
) -> int:
"""
Подсчитывает количество промогрупп у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
Количество промогрупп
"""
try:
result = await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
return len(list(result.scalars().all()))
except Exception as error:
logger.error(f"Ошибка подсчета промогрупп пользователя: {error}")
return 0
async def replace_user_promo_groups(
db: AsyncSession,
user_id: int,
promo_group_ids: List[int],
assigned_by: str = "admin"
) -> bool:
"""
Заменяет все промогруппы пользователя на новый список.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_ids: Список ID промогрупп
assigned_by: Кто назначил
Returns:
True если успешно
"""
try:
# Удаляем все текущие промогруппы
await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
result = await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
for upg in result.scalars().all():
await db.delete(upg)
# Добавляем новые
for promo_group_id in promo_group_ids:
user_promo_group = UserPromoGroup(
user_id=user_id,
promo_group_id=promo_group_id,
assigned_by=assigned_by
)
db.add(user_promo_group)
await db.commit()
logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}")
return True
except Exception as error:
logger.error(f"Ошибка замены промогрупп пользователя: {error}")
await db.rollback()
return False
+14 -360
View File
@@ -1,167 +1,31 @@
import logging
from typing import AsyncGenerator, Optional
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
async_sessionmaker,
AsyncEngine
)
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
from sqlalchemy import event, text, bindparam, inspect
from sqlalchemy.engine import Engine
import time
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
# ============================================================================
if settings.get_database_url().startswith("sqlite"):
poolclass = NullPool
pool_kwargs = {}
else:
poolclass = AsyncAdaptedQueuePool
pool_kwargs = {
"pool_size": 20,
"max_overflow": 30,
"pool_timeout": 30,
"pool_recycle": 3600,
"pool_pre_ping": True,
# 🔥 Агрессивная очистка мертвых соединений
"pool_reset_on_return": "rollback",
}
# ============================================================================
# ENGINE WITH ADVANCED OPTIMIZATIONS
# ============================================================================
engine = create_async_engine(
settings.get_database_url(),
poolclass=poolclass,
poolclass=NullPool,
echo=settings.DEBUG,
future=True,
**pool_kwargs,
connect_args={
"server_settings": {
"application_name": "remnawave_bot",
"jit": "on",
"statement_timeout": "60000", # 60 секунд
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
} if not settings.get_database_url().startswith("sqlite") else {},
execution_options={
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
"compiled_cache_size": 500, # Кеш скомпилированных запросов
}
future=True
)
# ============================================================================
# SESSION FACTORY WITH OPTIMIZATIONS
# ============================================================================
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False, # 🔥 Критично для производительности
autocommit=False,
autoflush=True,
autocommit=False
)
# ============================================================================
# QUERY PERFORMANCE MONITORING
# ============================================================================
if settings.DEBUG:
@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
conn.info.setdefault("query_start_time", []).append(time.time())
logger.debug(f"🔍 Executing query: {statement[:100]}...")
@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
total = time.time() - conn.info["query_start_time"].pop(-1)
if total > 0.1: # Логируем медленные запросы > 100ms
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
else:
logger.debug(f"⚡ Query executed in {total:.3f}s")
# ============================================================================
# ADVANCED SESSION MANAGER WITH READ REPLICAS
# ============================================================================
class DatabaseManager:
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
def __init__(self):
self.engine = engine
self.read_replica_engine: Optional[AsyncEngine] = None
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
self.read_replica_engine = create_async_engine(
settings.DATABASE_READ_REPLICA_URL,
poolclass=poolclass,
pool_size=30, # Больше для read операций
max_overflow=50,
pool_pre_ping=True,
echo=False,
)
@asynccontextmanager
async def session(self, read_only: bool = False):
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
async_session = async_sessionmaker(
bind=target_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async with async_session() as session:
try:
yield session
if not read_only:
await session.commit()
except Exception:
await session.rollback()
raise
async def health_check(self) -> dict:
pool = self.engine.pool
try:
async with AsyncSessionLocal() as session:
start = time.time()
await session.execute(text("SELECT 1"))
latency = (time.time() - start) * 1000
status = "healthy"
except Exception as e:
logger.error(f"❌ Database health check failed: {e}")
status = "unhealthy"
latency = None
return {
"status": status,
"latency_ms": round(latency, 2) if latency else None,
"pool": _collect_health_pool_metrics(pool),
}
db_manager = DatabaseManager()
# ============================================================================
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
# ============================================================================
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Стандартная dependency для FastAPI"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -169,229 +33,19 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
"""Read-only dependency для тяжелых SELECT запросов"""
async with db_manager.session(read_only=True) as session:
yield session
# ============================================================================
# BATCH OPERATIONS FOR PERFORMANCE
# ============================================================================
class BatchOperations:
"""Утилиты для массовых операций"""
@staticmethod
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовая вставка с чанками"""
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
session.add_all([model(**item) for item in chunk])
await session.flush()
await session.commit()
@staticmethod
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовое обновление с чанками"""
if not data:
return
primary_keys = [column.name for column in model.__table__.primary_key.columns]
if not primary_keys:
raise ValueError("Model must have a primary key for bulk_update")
updatable_columns = [
column.name
for column in model.__table__.columns
if column.name not in primary_keys
]
if not updatable_columns:
raise ValueError("No columns available for update in bulk_update")
stmt = (
model.__table__.update()
.where(
*[
getattr(model.__table__.c, pk) == bindparam(pk)
for pk in primary_keys
]
)
.values(
**{
column: bindparam(column, required=False)
for column in updatable_columns
}
)
)
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
filtered_chunk = []
for item in chunk:
missing_keys = [pk for pk in primary_keys if pk not in item]
if missing_keys:
raise ValueError(
f"Missing primary key values {missing_keys} for bulk_update"
)
filtered_item = {
key: value
for key, value in item.items()
if key in primary_keys or key in updatable_columns
}
filtered_chunk.append(filtered_item)
await session.execute(stmt, filtered_chunk)
await session.commit()
batch_ops = BatchOperations()
# ============================================================================
# INITIALIZATION AND CLEANUP
# ============================================================================
async def init_db():
"""Инициализация БД с оптимизациями"""
logger.info("🚀 Создание таблиц базы данных...")
logger.info("Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if not settings.get_database_url().startswith("sqlite"):
logger.info("📊 Создание индексов для оптимизации...")
async with engine.begin() as conn:
indexes = [
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
),
(
"payments",
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
),
]
for table_name, index_sql in indexes:
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
if not table_exists:
logger.debug(
"Пропускаем создание индекса %s: таблица %s отсутствует",
index_sql,
table_name,
)
continue
try:
await conn.execute(text(index_sql))
except Exception as e:
logger.debug("Index creation skipped for %s: %s", table_name, e)
logger.info("✅ База данных успешно инициализирована")
health = await db_manager.health_check()
logger.info(f"📊 Database health: {health}")
async def close_db():
"""Корректное закрытие всех соединений"""
logger.info("🔄 Закрытие соединений с БД...")
await engine.dispose()
if db_manager.read_replica_engine:
await db_manager.read_replica_engine.dispose()
logger.info("✅ Все подключения к базе данных закрыты")
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
# ============================================================================
def _pool_counters(pool):
"""Return basic pool counters or ``None`` when unsupported."""
required_methods = ("size", "checkedin", "checkedout", "overflow")
for method_name in required_methods:
method = getattr(pool, method_name, None)
if method is None or not callable(method):
return None
size = pool.size()
checked_in = pool.checkedin()
checked_out = pool.checkedout()
overflow = pool.overflow()
total_connections = size + overflow
return {
"size": size,
"checked_in": checked_in,
"checked_out": checked_out,
"overflow": overflow,
"total_connections": total_connections,
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
}
def _collect_health_pool_metrics(pool) -> dict:
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"size": 0,
"checked_in": 0,
"checked_out": 0,
"overflow": 0,
"total_connections": 0,
"utilization": "0.0%",
}
return {
"metrics_available": True,
"size": counters["size"],
"checked_in": counters["checked_in"],
"checked_out": counters["checked_out"],
"overflow": counters["overflow"],
"total_connections": counters["total_connections"],
"utilization": f"{counters['utilization_percent']:.1f}%",
}
async def get_pool_metrics() -> dict:
"""Детальные метрики пула для Prometheus/Grafana"""
pool = engine.pool
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"pool_size": 0,
"checked_in_connections": 0,
"checked_out_connections": 0,
"overflow_connections": 0,
"total_connections": 0,
"max_possible_connections": 0,
"pool_utilization_percent": 0.0,
}
return {
"metrics_available": True,
"pool_size": counters["size"],
"checked_in_connections": counters["checked_in"],
"checked_out_connections": counters["checked_out"],
"overflow_connections": counters["overflow"],
"total_connections": counters["total_connections"],
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
"pool_utilization_percent": round(counters["utilization_percent"], 2),
}
logger.info("✅ Подключение к базе данных закрыто")
+11 -111
View File
@@ -67,10 +67,9 @@ class TransactionType(Enum):
class PromoCodeType(Enum):
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
PROMO_GROUP = "promo_group"
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
class PaymentMethod(Enum):
@@ -82,7 +81,6 @@ class PaymentMethod(Enum):
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
PLATEGA = "platega"
MANUAL = "manual"
@@ -415,62 +413,11 @@ class WataPayment(Base):
)
class PlategaPayment(Base):
__tablename__ = "platega_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
payment_method_code = Column(Integer, nullable=False)
status = Column(String(50), nullable=False, default="PENDING")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
redirect_url = Column(Text, nullable=True)
return_url = Column(Text, nullable=True)
failed_url = Column(Text, nullable=True)
payload = Column(String(255), nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="platega_payments")
transaction = relationship("Transaction", backref="platega_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
self.id,
self.platega_transaction_id,
self.amount_rubles,
self.status,
self.payment_method_code,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), unique=True, nullable=False)
priority = Column(Integer, nullable=False, default=0, index=True)
server_discount_percent = Column(Integer, nullable=False, default=0)
traffic_discount_percent = Column(Integer, nullable=False, default=0)
device_discount_percent = Column(Integer, nullable=False, default=0)
@@ -482,7 +429,6 @@ class PromoGroup(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
users = relationship("User", back_populates="promo_group")
user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan")
server_squads = relationship(
"ServerSquad",
secondary=server_squad_promo_groups,
@@ -546,22 +492,6 @@ class PromoGroup(Base):
return max(0, min(100, percent))
class UserPromoGroup(Base):
"""Таблица связи Many-to-Many между пользователями и промогруппами."""
__tablename__ = "user_promo_groups"
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True)
assigned_at = Column(DateTime, default=func.now())
assigned_by = Column(String(50), default="system")
user = relationship("User", back_populates="user_promo_groups")
promo_group = relationship("PromoGroup", back_populates="user_promo_groups")
def __repr__(self):
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
class User(Base):
__tablename__ = "users"
@@ -599,51 +529,23 @@ class User(Base):
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
poll_responses = relationship("PollResponse", back_populates="user")
@property
def balance_rubles(self) -> float:
return self.balance_kopeks / 100
@property
def full_name(self) -> str:
parts = [self.first_name, self.last_name]
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
def get_primary_promo_group(self):
"""Возвращает промогруппу с максимальным приоритетом."""
if not self.user_promo_groups:
return getattr(self, "promo_group", None)
try:
# Сортируем по приоритету группы (убывание), затем по ID группы
# Используем getattr для защиты от ленивой загрузки
sorted_groups = sorted(
self.user_promo_groups,
key=lambda upg: (
getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0,
upg.promo_group_id
),
reverse=True
)
if sorted_groups and sorted_groups[0].promo_group:
return sorted_groups[0].promo_group
except Exception:
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
pass
# Fallback на старую связь если новая пустая или возникла ошибка
return getattr(self, "promo_group", None)
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
primary_group = self.get_primary_promo_group()
if not primary_group:
if not self.promo_group:
return 0
return primary_group.get_discount_percent(category, period_days)
return self.promo_group.get_discount_percent(category, period_days)
def add_balance(self, kopeks: int) -> None:
self.balance_kopeks += kopeks
@@ -891,15 +793,13 @@ class PromoCode(Base):
valid_until = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
uses = relationship("PromoCodeUse", back_populates="promocode")
promo_group = relationship("PromoGroup")
@property
def is_valid(self) -> bool:
+1 -272
View File
@@ -2107,9 +2107,6 @@ async def ensure_promo_groups_setup():
addon_discount_column_exists = await check_column_exists(
"promo_groups", "apply_discounts_to_addons"
)
priority_column_exists = await check_column_exists(
"promo_groups", "priority"
)
if not addon_discount_column_exists:
if db_type == "sqlite":
@@ -2292,23 +2289,7 @@ async def ensure_promo_groups_setup():
"is_default": True,
}
if priority_column_exists:
insert_params["priority"] = 0
if addon_discount_column_exists and priority_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
priority,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
apply_discounts_to_addons,
is_default
) VALUES (:name, :priority, 0, 0, 0, :apply_discounts_to_addons, :is_default)
"""
insert_params["apply_discounts_to_addons"] = True
elif addon_discount_column_exists:
if addon_discount_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
@@ -2320,17 +2301,6 @@ async def ensure_promo_groups_setup():
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
"""
insert_params["apply_discounts_to_addons"] = True
elif priority_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
priority,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
is_default
) VALUES (:name, :priority, 0, 0, 0, :is_default)
"""
else:
insert_sql = """
INSERT INTO promo_groups (
@@ -3489,219 +3459,6 @@ async def ensure_default_web_api_token() -> bool:
return False
async def add_promo_group_priority_column() -> bool:
"""Добавляет колонку priority в таблицу promo_groups."""
column_exists = await check_column_exists('promo_groups', 'priority')
if column_exists:
logger.info("Колонка priority уже существует в promo_groups")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
column_def = 'INTEGER NOT NULL DEFAULT 0'
elif db_type == 'postgresql':
column_def = 'INTEGER NOT NULL DEFAULT 0'
else:
column_def = 'INT NOT NULL DEFAULT 0'
await conn.execute(
text(f"ALTER TABLE promo_groups ADD COLUMN priority {column_def}")
)
# Создаем индекс для оптимизации сортировки
if db_type == 'postgresql':
await conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)")
)
elif db_type == 'sqlite':
await conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)")
)
else: # MySQL
await conn.execute(
text("CREATE INDEX idx_promo_groups_priority ON promo_groups(priority DESC)")
)
logger.info("✅ Добавлена колонка priority в promo_groups с индексом")
return True
except Exception as error:
logger.error(f"Ошибка добавления колонки priority: {error}")
return False
async def create_user_promo_groups_table() -> bool:
"""Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups."""
table_exists = await check_table_exists("user_promo_groups")
if table_exists:
logger.info("️ Таблица user_promo_groups уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE user_promo_groups (
user_id INTEGER NOT NULL,
promo_group_id INTEGER NOT NULL,
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
assigned_by VARCHAR(50) DEFAULT 'system',
PRIMARY KEY (user_id, promo_group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
);
"""
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
elif db_type == "postgresql":
create_sql = """
CREATE TABLE user_promo_groups (
user_id INTEGER NOT NULL,
promo_group_id INTEGER NOT NULL,
assigned_at TIMESTAMP DEFAULT NOW(),
assigned_by VARCHAR(50) DEFAULT 'system',
PRIMARY KEY (user_id, promo_group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
);
"""
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
else: # MySQL
create_sql = """
CREATE TABLE user_promo_groups (
user_id INT NOT NULL,
promo_group_id INT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
assigned_by VARCHAR(50) DEFAULT 'system',
PRIMARY KEY (user_id, promo_group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
);
"""
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
await conn.execute(text(create_sql))
await conn.execute(text(index_sql))
logger.info("✅ Таблица user_promo_groups создана с индексом")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы user_promo_groups: {error}")
return False
async def migrate_existing_user_promo_groups_data() -> bool:
"""Переносит существующие связи users.promo_group_id в таблицу user_promo_groups."""
try:
table_exists = await check_table_exists("user_promo_groups")
if not table_exists:
logger.warning("⚠️ Таблица user_promo_groups не существует, пропускаем миграцию данных")
return False
column_exists = await check_column_exists('users', 'promo_group_id')
if not column_exists:
logger.warning("⚠️ Колонка users.promo_group_id не существует, пропускаем миграцию данных")
return True
async with engine.begin() as conn:
# Проверяем есть ли уже данные в user_promo_groups
result = await conn.execute(text("SELECT COUNT(*) FROM user_promo_groups"))
count = result.scalar()
if count > 0:
logger.info(f"ℹ️ В таблице user_promo_groups уже есть {count} записей, пропускаем миграцию")
return True
# Переносим данные из users.promo_group_id
db_type = await get_database_type()
if db_type == "sqlite":
migrate_sql = """
INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by)
SELECT id, promo_group_id, CURRENT_TIMESTAMP, 'system'
FROM users
WHERE promo_group_id IS NOT NULL
"""
else: # PostgreSQL and MySQL
migrate_sql = """
INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by)
SELECT id, promo_group_id, NOW(), 'system'
FROM users
WHERE promo_group_id IS NOT NULL
"""
result = await conn.execute(text(migrate_sql))
migrated_count = result.rowcount if hasattr(result, 'rowcount') else 0
logger.info(f"✅ Перенесено {migrated_count} связей пользователей с промогруппами")
return True
except Exception as error:
logger.error(f"❌ Ошибка миграции данных user_promo_groups: {error}")
return False
async def add_promocode_promo_group_column() -> bool:
"""Добавляет колонку promo_group_id в таблицу promocodes."""
column_exists = await check_column_exists('promocodes', 'promo_group_id')
if column_exists:
logger.info("Колонка promo_group_id уже существует в promocodes")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
# Add column
if db_type == 'sqlite':
await conn.execute(
text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER")
)
elif db_type == 'postgresql':
await conn.execute(
text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER")
)
# Add foreign key
await conn.execute(
text("""
ALTER TABLE promocodes
ADD CONSTRAINT fk_promocodes_promo_group
FOREIGN KEY (promo_group_id)
REFERENCES promo_groups(id)
ON DELETE SET NULL
""")
)
# Add index
await conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_promocodes_promo_group_id ON promocodes(promo_group_id)")
)
elif db_type == 'mysql':
await conn.execute(
text("""
ALTER TABLE promocodes
ADD COLUMN promo_group_id INT,
ADD CONSTRAINT fk_promocodes_promo_group
FOREIGN KEY (promo_group_id)
REFERENCES promo_groups(id)
ON DELETE SET NULL
""")
)
await conn.execute(
text("CREATE INDEX idx_promocodes_promo_group_id ON promocodes(promo_group_id)")
)
logger.info("✅ Добавлена колонка promo_group_id в promocodes")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления promo_group_id в promocodes: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -3863,34 +3620,6 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
logger.info("=== ДОБАВЛЕНИЕ ПРИОРИТЕТА В ПРОМОГРУППЫ ===")
priority_column_ready = await add_promo_group_priority_column()
if priority_column_ready:
logger.info("✅ Колонка priority в promo_groups готова")
else:
logger.warning("⚠️ Проблемы с добавлением priority в promo_groups")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===")
user_promo_groups_ready = await create_user_promo_groups_table()
if user_promo_groups_ready:
logger.info("✅ Таблица user_promo_groups готова")
else:
logger.warning("⚠️ Проблемы с таблицей user_promo_groups")
logger.info("=== МИГРАЦИЯ ДАННЫХ В USER_PROMO_GROUPS ===")
data_migrated = await migrate_existing_user_promo_groups_data()
if data_migrated:
logger.info("✅ Данные перенесены в user_promo_groups")
else:
logger.warning("⚠️ Проблемы с миграцией данных в user_promo_groups")
logger.info("=== ДОБАВЛЕНИЕ PROMO_GROUP_ID В PROMOCODES ===")
promocode_column_ready = await add_promocode_promo_group_column()
if promocode_column_ready:
logger.info("✅ Колонка promo_group_id в promocodes готова")
else:
logger.warning("⚠️ Проблемы с добавлением promo_group_id в promocodes")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
main_menu_buttons_created = await create_main_menu_buttons_table()
if main_menu_buttons_created:
+4 -4
View File
@@ -1,4 +1,4 @@
"""Flask webhook server for PayPalych callbacks."""
"""Flask webhook server for PayPalych postbacks."""
from __future__ import annotations
@@ -65,7 +65,7 @@ def create_pal24_flask_app(
return jsonify({"status": "error", "reason": "empty_payload"}), 400
try:
parsed_payload = pal24_service.parse_callback(payload)
parsed_payload = pal24_service.parse_postback(payload)
except Pal24APIError as error:
logger.error("Ошибка валидации Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": str(error)}), 400
@@ -73,7 +73,7 @@ def create_pal24_flask_app(
async def process() -> bool:
async with AsyncSessionLocal() as db:
try:
return await payment_service.process_pal24_callback(db, parsed_payload)
return await payment_service.process_pal24_postback(db, parsed_payload)
except Exception:
await db.rollback()
raise
@@ -112,7 +112,7 @@ def create_pal24_flask_app(
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 callbacks."""
"""Threaded Flask server for Pal24 postbacks."""
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
self.app = create_pal24_flask_app(payment_service, loop)
+1 -1
View File
@@ -163,7 +163,7 @@ class RemnaWaveAPI:
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
headers = self._prepare_auth_headers()
+127 -268
View File
@@ -1,16 +1,12 @@
import asyncio
import logging
import json
from ipaddress import (
IPv4Address,
IPv4Network,
IPv6Address,
IPv6Network,
ip_address,
ip_network,
)
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
import hashlib
import hmac
import base64
from typing import Optional, Dict, Any
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.payment_service import PaymentService
@@ -19,292 +15,155 @@ from app.database.database import get_db
logger = logging.getLogger(__name__)
IPAddress = Union[IPv4Address, IPv6Address]
IPNetwork = Union[IPv4Network, IPv6Network]
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("185.71.76.0/27"),
ip_network("185.71.77.0/27"),
ip_network("77.75.153.0/25"),
ip_network("77.75.154.128/25"),
ip_network("77.75.156.11/32"),
ip_network("77.75.156.35/32"),
ip_network("2a02:5180::/32"),
)
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("173.245.48.0/20"),
ip_network("103.21.244.0/22"),
ip_network("103.22.200.0/22"),
ip_network("103.31.4.0/22"),
ip_network("141.101.64.0/18"),
ip_network("108.162.192.0/18"),
ip_network("190.93.240.0/20"),
ip_network("188.114.96.0/20"),
ip_network("197.234.240.0/22"),
ip_network("198.41.128.0/17"),
ip_network("162.158.0.0/15"),
ip_network("104.16.0.0/13"),
ip_network("104.24.0.0/14"),
ip_network("172.64.0.0/13"),
ip_network("131.0.72.0/22"),
ip_network("2400:cb00::/32"),
ip_network("2606:4700::/32"),
ip_network("2803:f800::/32"),
ip_network("2405:b500::/32"),
ip_network("2405:8100::/32"),
ip_network("2a06:98c0::/29"),
ip_network("2c0f:f248::/32"),
)
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
"payment.succeeded",
"payment.waiting_for_capture",
"payment.canceled",
)
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
candidates: List[str] = []
for value in values:
if not value:
continue
for part in value.split(","):
normalized = part.strip()
if normalized:
candidates.append(normalized)
return candidates
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
value = candidate.strip()
if not value:
return None
if value.startswith("[") and "]" in value:
value = value[1:value.index("]")]
if "%" in value:
value = value.split("%", 1)[0]
if value.count(":") == 1 and "." in value:
host, _, port = value.rpartition(":")
if port.isdigit():
value = host
try:
return ip_address(value)
except ValueError:
return None
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
if remote_ip is None:
return True
if _is_trusted_proxy_ip(remote_ip):
return True
return any(
getattr(remote_ip, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
)
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
global _TRUSTED_PROXY_NETWORKS_CACHE
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
if raw_value == cached_raw:
return cached_networks
networks: List[IPNetwork] = []
for part in raw_value.split(","):
candidate = part.strip()
if not candidate:
continue
try:
networks.append(ip_network(candidate, strict=False))
except ValueError:
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
cached_networks = tuple(networks)
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
return cached_networks
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
if any(
getattr(ip_object, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
):
return True
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
return True
return any(ip_object in network for network in _get_trusted_proxy_networks())
def resolve_yookassa_ip(
candidates: Iterable[str],
*,
remote: Optional[str] = None,
) -> Optional[IPAddress]:
remote_ip = _parse_candidate_ip(remote) if remote else None
if (
remote_ip is not None
and remote_ip.is_global
and not _is_trusted_proxy_ip(remote_ip)
):
return remote_ip
candidate_list = list(candidates)
if _should_trust_forwarded_headers(remote_ip):
last_hop = remote_ip
for candidate in reversed(candidate_list):
ip_object = _parse_candidate_ip(candidate)
if ip_object is not None:
if last_hop is None or _is_trusted_proxy_ip(last_hop):
if _is_trusted_proxy_ip(ip_object):
last_hop = ip_object
continue
return ip_object
break
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
return last_hop
return remote_ip if remote_ip is not None else next(
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
None,
)
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
class YooKassaWebhookHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
try:
signature_parts = signature.strip().split(' ')
if len(signature_parts) < 4:
logger.error(f"Неверный формат подписи YooKassa: {signature}")
return False
version = signature_parts[0]
payment_id = signature_parts[1]
timestamp = signature_parts[2]
received_signature = signature_parts[3]
if version != "v1":
logger.error(f"Неподдерживаемая версия подписи: {version}")
return False
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
expected_signature_1 = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
expected_signature_2 = hmac.new(
secret.encode('utf-8'),
signed_payload_2.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
signed_payload_3 = f"{timestamp}.{body}"
expected_signature_3 = hmac.new(
secret.encode('utf-8'),
signed_payload_3.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
logger.debug(f"Получена подпись: {received_signature}")
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
is_valid = (
hmac.compare_digest(received_signature, expected_signature_1_b64) or
hmac.compare_digest(received_signature, expected_signature_2_b64) or
hmac.compare_digest(received_signature, expected_signature_3_b64)
)
if is_valid:
logger.info("✅ Подпись YooKassa webhook проверена успешно")
else:
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
return False
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
header_ip_candidates = collect_yookassa_ip_candidates(
request.headers.get("X-Forwarded-For"),
request.headers.get("X-Real-IP"),
request.headers.get("Cf-Connecting-Ip"),
)
client_ip = resolve_yookassa_ip(
header_ip_candidates,
remote=request.remote,
)
if client_ip is None:
logger.warning(
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
header_ip_candidates + ([request.remote] if request.remote else []),
)
return web.Response(status=403, text="Forbidden")
if not is_yookassa_ip_allowed(client_ip):
logger.warning(
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
client_ip,
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
)
return web.Response(status=403, text="Forbidden")
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
body = await request.text()
if not body:
logger.warning("⚠️ Получен пустой webhook от YooKassa")
return web.Response(status=400, text="Empty body")
logger.info(f"📄 Body: {body}")
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
if signature:
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
logger.info(f"🔐 Получена подпись: {signature}")
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
else:
logger.info("✅ Подпись webhook проверена успешно")
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
else:
logger.info("ℹ️ Проверка подписи отключена")
try:
webhook_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
return web.Response(status=400, text="Invalid JSON")
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
# Извлекаем ID платежа из вебхука для предотвращения дублирования
yookassa_payment_id = webhook_data.get("object", {}).get("id")
if not yookassa_payment_id:
logger.warning("⚠️ Webhook YooKassa без ID платежа")
return web.Response(status=400, text="No payment ID")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
if event_type not in YOOKASSA_ALLOWED_EVENTS:
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK")
async for db in get_db():
try:
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
from app.database.models import PaymentMethod
from app.database.crud.transaction import get_transaction_by_external_id
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
if existing_transaction and event_type == "payment.succeeded":
logger.info(f"️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
return web.Response(status=200, text="OK")
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
return web.Response(status=200, text="OK")
else:
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
return web.Response(status=500, text="Processing error")
finally:
await db.close()
except Exception as e:
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
return web.Response(status=500, text="Internal server error")
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
async def _get_handler(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
@@ -313,7 +172,7 @@ class YooKassaWebhookHandler:
"path": request.path,
"note": "Use POST method for actual webhooks"
})
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
@@ -326,46 +185,46 @@ class YooKassaWebhookHandler:
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response({
"status": "ok",
"status": "ok",
"service": "yookassa_webhook",
"port": settings.YOOKASSA_WEBHOOK_PORT,
"path": settings.YOOKASSA_WEBHOOK_PATH,
"enabled": settings.is_yookassa_enabled()
})
app.router.add_get("/health", health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
return
try:
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(
"✅ YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
@@ -377,7 +236,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
await asyncio.sleep(1)
@@ -387,7 +246,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
await site.stop()
await runner.cleanup()
logger.info("✅ YooKassa webhook сервер остановлен")
except Exception as e:
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
raise
raise
+25 -22
View File
@@ -42,16 +42,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"title": "🤖 Основные",
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
"icon": "🤖",
"categories": (
"CORE",
"CHANNEL",
"TIMEZONE",
"DATABASE",
"POSTGRES",
"SQLITE",
"REDIS",
"REMNAWAVE",
),
"categories": ("CORE", "CHANNEL"),
},
"support": {
"title": "💬 Поддержка",
@@ -61,18 +52,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
"icon": "💳",
"categories": (
"PAYMENT",
"PAYMENT_VERIFICATION",
"YOOKASSA",
"CRYPTOBOT",
"HELEKET",
"MULENPAY",
"PAL24",
"WATA",
"PLATEGA",
"TRIBUTE",
"TELEGRAM",
),
@@ -125,6 +114,18 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"ADDITIONAL",
),
},
"database": {
"title": "💾 База данных",
"description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
"icon": "💾",
"categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
},
"remnawave": {
"title": "🌐 RemnaWave API",
"description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
"icon": "🌐",
"categories": ("REMNAWAVE",),
},
"server": {
"title": "📊 Статус серверов",
"description": "Мониторинг серверов, SLA и внешние метрики.",
@@ -141,14 +142,13 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"title": "⚡ Расширенные",
"description": "Web API, webhook, логирование, модерация и режим отладки.",
"icon": "",
"categories": (
"WEB_API",
"WEBHOOK",
"LOG",
"MODERATION",
"DEBUG",
"EXTERNAL_ADMIN",
),
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
},
"external_admin": {
"title": "🛡️ Внешняя админка",
"description": "Токен, по которому внешняя админка проверяет запросы.",
"icon": "🛡️",
"categories": ("EXTERNAL_ADMIN",),
},
}
@@ -161,9 +161,12 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
"referral",
"notifications",
"interface",
"database",
"remnawave",
"server",
"maintenance",
"advanced",
"external_admin",
)
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
@@ -254,7 +257,6 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
payment_statuses = {
"YooKassa": settings.is_yookassa_enabled(),
"CryptoBot": settings.is_cryptobot_enabled(),
"Platega": settings.is_platega_enabled(),
"MulenPay": settings.is_mulenpay_enabled(),
"PAL24": settings.is_pal24_enabled(),
"Tribute": settings.TRIBUTE_ENABLED,
@@ -311,6 +313,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
settings.REFERRAL_COMMISSION_PERCENT
or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
or settings.REFERRAL_INVITER_BONUS_KOPEKS
or settings.get_referred_user_reward_kopeks()
)
return ("🟢", "Программа активна") if active else ("", "Бонусы не заданы")
+2 -7
View File
@@ -46,9 +46,6 @@ def _format_campaign_summary(campaign, texts) -> str:
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
else:
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
bonus_info = (
"📱 Подписка: <b>{days} д.</b>\n"
"🌐 Трафик: <b>{traffic}</b>\n"
@@ -56,7 +53,7 @@ def _format_campaign_summary(campaign, texts) -> str:
).format(
days=campaign.subscription_duration_days or 0,
traffic=traffic_text,
devices=device_limit,
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
)
return (
@@ -938,9 +935,7 @@ async def start_edit_campaign_subscription_devices(
campaign_edit_message_is_caption=is_caption,
)
current_devices = campaign.subscription_device_limit
if current_devices is None:
current_devices = settings.DEFAULT_DEVICE_LIMIT
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
await callback.message.edit_text(
(
+1 -1
View File
@@ -449,4 +449,4 @@ def register_handlers(dp: Dispatcher):
dp.message.register(
admin_commands_help,
Command("admin_help")
)
)
+1 -6
View File
@@ -74,7 +74,6 @@ async def show_maintenance_panel(
{status_emoji} <b>Режим техработ:</b> {status_text}
{api_emoji} <b>API Remnawave:</b> {api_text}
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
🛠 <b>Автозапуск мониторинга:</b> {'Включен' if status_info['monitoring_configured'] else 'Отключен'}
<b>Интервал проверки:</b> {status_info['check_interval']}с
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
{panel_info}
@@ -234,11 +233,7 @@ async def check_panel_status(
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
]
attempts_used = status_data.get("attempts_used")
if attempts_used:
message_parts.append(f"🔁 Попыток проверки: {attempts_used}")
if status_data.get("api_error"):
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
+38 -59
View File
@@ -764,69 +764,48 @@ async def confirm_broadcast(
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
async def send_single_broadcast(user):
"""Отправляет одно сообщение рассылки с семафором ограничения"""
async with semaphore:
try:
if has_media and media_file_id:
if media_type == "photo":
await callback.bot.send_photo(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "video":
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "document":
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
else:
await callback.bot.send_message(
for user in users:
try:
if has_media and media_file_id:
if media_type == "photo":
await callback.bot.send_photo(
chat_id=user.telegram_id,
text=message_text,
photo=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
return True, user.telegram_id
except Exception as e:
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
return False, user.telegram_id
# Отправляем сообщения пакетами для эффективности
batch_size = 100
for i in range(0, len(users), batch_size):
batch = users[i:i + batch_size]
tasks = [send_single_broadcast(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, tuple): # (success, telegram_id)
success, _ = result
if success:
sent_count += 1
else:
failed_count += 1
elif isinstance(result, Exception):
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
elif media_type == "video":
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "document":
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
else:
await callback.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
sent_count += 1
if sent_count % 20 == 0:
await asyncio.sleep(1)
except Exception as e:
failed_count += 1
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
+1 -15
View File
@@ -1027,20 +1027,6 @@ async def process_notification_value_input(message: Message, state: FSMContext):
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
# Добавляем дополнительные проверки диапазона значений
if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"):
if value < 0 or value > 100:
await message.answer("❌ Процент скидки должен быть от 0 до 100.")
return
elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"):
if value < 1 or value > 168: # Максимум 168 часов (7 дней)
await message.answer("❌ Количество часов должно быть от 1 до 168.")
return
elif key == "expired_third_wave" and field == "trigger":
if value < 2: # Минимум 2 дня
await message.answer("❌ Количество дней должно быть не менее 2.")
return
success = False
if key == "expired_second_wave" and field == "percent":
success = NotificationSettingsService.set_second_wave_discount_percent(value)
@@ -1089,4 +1075,4 @@ async def process_notification_value_input(message: Message, state: FSMContext):
def register_handlers(dp):
dp.include_router(router)
dp.include_router(router)
-16
View File
@@ -37,8 +37,6 @@ def _method_display(method: PaymentMethod) -> str:
return "Heleket"
if method == PaymentMethod.YOOKASSA:
return "YooKassa"
if method == PaymentMethod.PLATEGA:
return "Platega"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.TELEGRAM_STARS:
@@ -92,18 +90,6 @@ def _status_info(
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.PLATEGA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"inprogress": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"confirmed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"failed": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancelled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.HELEKET:
if status in {"pending", "created", "waiting", "check", "processing"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
@@ -150,8 +136,6 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {"created", "processing", "hold"}
if record.method == PaymentMethod.WATA:
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
if record.method == PaymentMethod.PLATEGA:
return status in {"pending", "inprogress", "in_progress"}
if record.method == PaymentMethod.HELEKET:
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
if record.method == PaymentMethod.YOOKASSA:
+10 -22
View File
@@ -96,24 +96,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
label_en="📱 Device limit",
action="input",
),
SettingEntry(
key="TRIAL_PAYMENT_ENABLED",
section="trial",
label_ru="💳 Платная активация",
label_en="💳 Paid activation",
action="toggle",
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
description_en="When enabled, the configured amount is charged during trial activation.",
),
SettingEntry(
key="TRIAL_ACTIVATION_PRICE",
section="trial",
label_ru="💰 Стоимость активации",
label_en="💰 Activation price",
action="price",
description_ru="Указывается в копейках. 0 — бесплатная активация.",
description_en="Amount in kopeks. 0 — free activation.",
),
SettingEntry(
key="TRIAL_ADD_REMAINING_DAYS_TO_PAID",
section="trial",
@@ -123,6 +105,15 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.",
description_en="When enabled, remaining trial days are added to paid subscription duration.",
),
SettingEntry(
key="TRIAL_SQUAD_UUID",
section="trial",
label_ru="🆔 Squad UUID",
label_en="🆔 Squad UUID",
action="input",
description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.",
description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.",
),
)
@@ -299,14 +290,11 @@ def _format_trial_summary(lang_code: str) -> str:
duration = settings.TRIAL_DURATION_DAYS
traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
devices = settings.TRIAL_DEVICE_LIMIT
price_note = ""
if settings.is_trial_paid_activation_enabled():
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
traffic_label = _format_traffic_label(traffic, lang_code, short=True)
devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱"
days_suffix = "д" if lang_code == "ru" else "d"
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}"
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}"
def _format_core_summary(lang_code: str) -> str:
+1 -104
View File
@@ -211,14 +211,6 @@ def _format_rubles(amount_kopeks: int) -> str:
return formatted.replace(",", " ")
def _format_priority_line(texts, group: PromoGroup) -> str:
priority = getattr(group, "priority", 0)
return texts.t(
"ADMIN_PROMO_GROUP_PRIORITY_LINE",
"🎯 Приоритет: {priority}",
).format(priority=priority)
def _format_auto_assign_line(texts, group: PromoGroup) -> str:
threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
@@ -302,7 +294,6 @@ def _build_edit_menu_content(
lines = [header]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_addon_discounts_line(texts, group))
lines.append(_format_priority_line(texts, group))
lines.append(_format_auto_assign_line(texts, group))
period_lines = _format_period_discounts_lines(texts, group, language)
@@ -327,15 +318,6 @@ def _build_edit_menu_content(
callback_data=f"promo_group_edit_field_{group.id}_name",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY",
"🎯 Приоритет",
),
callback_data=f"promo_group_edit_field_{group.id}_priority",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
@@ -658,32 +640,6 @@ async def process_create_group_name(message: types.Message, state: FSMContext):
return
await state.update_data(new_group_name=name)
await state.set_state(AdminStates.creating_promo_group_priority)
texts = get_texts((await state.get_data()).get("language", "ru"))
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT",
"Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
)
)
async def process_create_group_priority(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
priority = int(message.text)
if priority < 0:
raise ValueError
except (ValueError, TypeError):
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
"❌ Приоритет должен быть неотрицательным целым числом",
)
)
return
await state.update_data(new_group_priority=priority)
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
await _prompt_for_discount(
message,
@@ -816,7 +772,6 @@ async def process_create_group_auto_assign(
group = await create_promo_group(
db,
data["new_group_name"],
priority=data.get("new_group_priority", 0),
traffic_discount_percent=data["new_group_traffic"],
server_discount_percent=data["new_group_servers"],
device_discount_percent=data["new_group_devices"],
@@ -907,12 +862,6 @@ async def prompt_edit_promo_group_field(
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
"Введите новое название промогруппы (текущее: {name}):",
).format(name=group.name)
elif field == "priority":
await state.set_state(AdminStates.editing_promo_group_priority)
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT",
"Введите новый приоритет (текущий: {current}):",
).format(current=getattr(group, "priority", 0))
elif field == "traffic":
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
prompt = texts.t(
@@ -986,48 +935,6 @@ async def process_edit_group_name(
)
@admin_required
@error_handler
async def process_edit_group_priority(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
priority = int(message.text)
if priority < 0:
raise ValueError
except (ValueError, TypeError):
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
"❌ Приоритет должен быть неотрицательным целым числом",
)
)
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(db, group, priority=priority)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def process_edit_group_traffic(
@@ -1251,9 +1158,8 @@ async def show_promo_group_members(
lines = []
for index, user in enumerate(members, start=offset + 1):
username = f"@{user.username}" if user.username else ""
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
lines.append(
f"{index}. {user_link} (ID {user.id}, {username}, TG {user.telegram_id})"
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
)
body = "\n".join(lines)
@@ -1275,7 +1181,6 @@ async def show_promo_group_members(
await callback.message.edit_text(
f"{title}\n\n{body}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML",
)
await callback.answer()
@@ -1418,10 +1323,6 @@ def register_handlers(dp: Dispatcher):
)
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
dp.message.register(
process_create_group_priority,
AdminStates.creating_promo_group_priority,
)
dp.message.register(
process_create_group_traffic,
AdminStates.creating_promo_group_traffic_discount,
@@ -1444,10 +1345,6 @@ def register_handlers(dp: Dispatcher):
)
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
dp.message.register(
process_edit_group_priority,
AdminStates.editing_promo_group_priority,
)
dp.message.register(
process_edit_group_traffic,
AdminStates.editing_promo_group_traffic_discount,
+52 -81
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
import html
import logging
import re
@@ -1897,94 +1896,66 @@ async def _send_offer_to_users(
squad_name: Optional[str],
effect_type: str,
) -> Tuple[int, int]:
from app.database.database import AsyncSessionLocal
sent = 0
failed = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
for user in users:
try:
offer_record = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=user.subscription.id if user.subscription else None,
notification_type=f"promo_template_{template.id}",
discount_percent=template.discount_percent,
bonus_amount_kopeks=0,
valid_hours=template.valid_hours,
effect_type=effect_type,
extra_data={
"template_id": template.id,
"offer_type": template.offer_type,
"test_duration_hours": template.test_duration_hours,
"test_squad_uuids": template.test_squad_uuids,
"active_discount_hours": template.active_discount_hours,
},
)
async def send_single_offer(user):
"""Отправляет одно предложение с семафором ограничения"""
async with semaphore:
try:
# Используем отдельную сессию для изоляции транзакции
async with AsyncSessionLocal() as new_db:
offer_record = await upsert_discount_offer(
new_db,
user_id=user.id,
subscription_id=getattr(user, "subscription", None).id if getattr(user, "subscription", None) else None,
notification_type=f"promo_template_{template.id}",
discount_percent=template.discount_percent,
bonus_amount_kopeks=0,
valid_hours=template.valid_hours,
effect_type=effect_type,
extra_data={
"template_id": template.id,
"offer_type": template.offer_type,
"test_duration_hours": template.test_duration_hours,
"test_squad_uuids": template.test_squad_uuids,
"active_discount_hours": template.active_discount_hours,
},
user_texts = get_texts(user.language or db_user.language)
keyboard_rows: List[List[InlineKeyboardButton]] = [
[
build_miniapp_or_callback_button(
text=template.button_text,
callback_data=f"claim_discount_{offer_record.id}",
)
]
]
user_texts = get_texts(user.language or db_user.language)
keyboard_rows: List[List[InlineKeyboardButton]] = [
[
build_miniapp_or_callback_button(
text=template.button_text,
callback_data=f"claim_discount_{offer_record.id}",
)
]
]
keyboard_rows.append([
InlineKeyboardButton(
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
callback_data="promo_offer_close",
)
])
keyboard_rows.append([
InlineKeyboardButton(
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
callback_data="promo_offer_close",
)
])
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
message_text = _render_template_text(
template,
user.language or db_user.language,
server_name=squad_name,
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
return False
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
return False
# Отправляем предложения пакетами для эффективности
batch_size = 100
for i in range(0, len(users), batch_size):
batch = users[i:i + batch_size]
tasks = [send_single_offer(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, bool): # Успешно или неуспешно
if result:
sent += 1
else:
failed += 1
elif isinstance(result, Exception): # Ошибка выполнения задачи
failed += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
message_text = _render_template_text(
template,
user.language or db_user.language,
server_name=squad_name,
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
sent += 1
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
failed += 1
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
failed += 1
return sent, failed
+42 -141
View File
@@ -15,9 +15,8 @@ from app.localization.texts import get_texts
from app.database.crud.promocode import (
get_promocodes_list, get_promocodes_count, create_promocode,
get_promocode_statistics, get_promocode_by_code, update_promocode,
delete_promocode, get_promocode_by_id
delete_promocode
)
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime
@@ -82,24 +81,16 @@ async def show_promocodes_list(
for promo in promocodes:
status_emoji = "" if promo.is_active else ""
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 Дней: {promo.subscription_days}\n"
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
if promo.valid_until:
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
@@ -138,20 +129,15 @@ async def show_promocode_management(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await get_promocode_by_id(db, promo_id)
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
status_emoji = "" if promo.is_active else ""
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
text = f"""
🎫 <b>Управление промокодом</b>
@@ -159,17 +145,12 @@ async def show_promocode_management(
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
"""
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
elif promo.promo_group_id:
text += f"🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n"
if promo.valid_until:
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
@@ -219,8 +200,8 @@ async def show_promocode_edit_menu(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await get_promocode_by_id(db, promo_id)
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -464,14 +445,13 @@ async def select_promocode_type(
state: FSMContext
):
promo_type = callback.data.split('_')[-1]
type_names = {
"balance": "💰 Пополнение баланса",
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка",
"group": "🏷️ Промогруппа"
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка"
}
await state.update_data(promocode_type=promo_type)
await callback.message.edit_text(
@@ -529,77 +509,6 @@ async def process_promocode_code(
f"Введите количество дней тестовой подписки:"
)
await state.set_state(AdminStates.setting_promocode_value)
elif promo_type == "group":
# Show promo group selection
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
if not groups_with_counts:
await message.answer(
"❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
])
)
await state.clear()
return
keyboard = []
text = f"🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n"
for promo_group, user_count in groups_with_counts:
text += f"{promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n"
keyboard.append([
types.InlineKeyboardButton(
text=f"{promo_group.name} (↑{promo_group.priority})",
callback_data=f"promo_select_group_{promo_group.id}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")
])
await message.answer(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await state.set_state(AdminStates.selecting_promo_group)
@admin_required
@error_handler
async def process_promo_group_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
"""Handle promo group selection for promocode"""
try:
promo_group_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True)
return
promo_group = await get_promo_group_by_id(db, promo_group_id)
if not promo_group:
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
return
await state.update_data(
promo_group_id=promo_group_id,
promo_group_name=promo_group.name
)
await callback.message.edit_text(
f"🏷️ <b>Промокод для промогруппы</b>\n\n"
f"Промогруппа: {promo_group.name}\n"
f"Приоритет: {promo_group.priority}\n\n"
f"📊 Введите количество использований промокода (или 0 для безлимита):"
)
await state.set_state(AdminStates.setting_promocode_uses)
await callback.answer()
@admin_required
@@ -648,8 +557,8 @@ async def handle_edit_value(
data = await state.get_data()
promo_id = data.get('editing_promo_id')
edit_action = data.get('edit_action')
promo = await get_promocode_by_id(db, promo_id)
promo = await db.get(PromoCode, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -734,8 +643,8 @@ async def handle_edit_uses(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await get_promocode_by_id(db, promo_id)
promo = await db.get(PromoCode, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -799,20 +708,17 @@ async def process_promocode_expiry(
promo_type = data.get('promocode_type')
value = data.get('promocode_value', 0)
max_uses = data.get('promocode_max_uses', 1)
promo_group_id = data.get('promo_group_id')
promo_group_name = data.get('promo_group_name')
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
type_map = {
"balance": PromoCodeType.BALANCE,
"days": PromoCodeType.SUBSCRIPTION_DAYS,
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
"group": PromoCodeType.PROMO_GROUP
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
}
promocode = await create_promocode(
db=db,
code=code,
@@ -821,31 +727,27 @@ async def process_promocode_expiry(
subscription_days=value if promo_type in ["days", "trial"] else 0,
max_uses=max_uses,
valid_until=valid_until,
created_by=db_user.id,
promo_group_id=promo_group_id if promo_type == "group" else None
created_by=db_user.id
)
type_names = {
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка",
"group": "Промогруппа"
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка"
}
summary_text = f"""
<b>Промокод создан!</b>
🎫 <b>Код:</b> <code>{promocode.code}</code>
📝 <b>Тип:</b> {type_names.get(promo_type)}
"""
if promo_type == "balance":
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
elif promo_type in ["days", "trial"]:
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
elif promo_type == "group" and promo_group_name:
summary_text += f"🏷️ <b>Промогруппа:</b> {promo_group_name}\n"
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
if promocode.valid_until:
@@ -873,8 +775,8 @@ async def handle_edit_expiry(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await get_promocode_by_id(db, promo_id)
promo = await db.get(PromoCode, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -920,8 +822,8 @@ async def toggle_promocode_status(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await get_promocode_by_id(db, promo_id)
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -947,8 +849,8 @@ async def confirm_delete_promocode(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await get_promocode_by_id(db, promo_id)
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -995,8 +897,8 @@ async def delete_promocode_confirmed(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await get_promocode_by_id(db, promo_id)
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -1019,8 +921,8 @@ async def show_promocode_stats(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await get_promocode_by_id(db, promo_id)
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -1105,7 +1007,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
+3
View File
@@ -68,6 +68,7 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
@@ -104,6 +105,7 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
<i>🕐 Время: {current_time}</i>
@@ -188,6 +190,7 @@ async def show_referral_settings(
Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
<b>Комиссионные:</b>
Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
+1 -2
View File
@@ -421,8 +421,7 @@ async def show_server_users(
lines = []
for index, user in enumerate(page_users, start=start_index + 1):
safe_user_name = html.escape(user.full_name)
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
lines.append(f"{index}. {user_link}")
lines.append(f"{index}. {safe_user_name}")
text += "\n" + "\n".join(lines)
else:
+15 -36
View File
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_
from datetime import datetime, timedelta
import time
import html
from app.database.models import User, Ticket, TicketStatus
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
@@ -175,24 +174,12 @@ async def view_admin_ticket(
user_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_value = ticket.user.username if ticket.user else None
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
ticket_text += f"👤 Пользователь: {user_name}\n"
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
if username_value:
safe_username = html.escape(username_value)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
else:
ticket_text += "📱 Username: отсутствует\n"
if ticket.user and ticket.user.telegram_id:
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
ticket_text += f"📱 Username: @{username_display}\n"
ticket_text += f"📝 Заголовок: {ticket.title}\n"
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
@@ -234,11 +221,15 @@ async def view_admin_ticket(
pass
# Кнопки ЛС и профиль
try:
if ticket.user and ticket.user.telegram_id and ticket.user.username:
safe_username = html.escape(ticket.user.username)
if ticket.user and ticket.user.telegram_id:
buttons_row = []
pm_url = f"tg://resolve?domain={safe_username}"
# DM: при наличии username используем tg://resolve, иначе fallback по ID
if ticket.user.username:
pm_url = f"tg://resolve?domain={ticket.user.username}"
else:
pm_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
# Профиль: по ID
profile_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
@@ -759,21 +750,7 @@ async def handle_admin_block_duration_input(
ticket_text += f"📝 Заголовок: {updated.title}\n"
ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n"
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n"
if updated.user and updated.user.telegram_id:
ticket_text += f"🆔 Telegram ID: <code>{updated.user.telegram_id}</code>\n"
if updated.user.username:
safe_username = html.escape(updated.user.username)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
else:
ticket_text += "📱 Username: отсутствует\n"
chat_link = f"tg://user?id={int(updated.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
if updated.is_user_reply_blocked:
if updated.user_reply_block_permanent:
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
@@ -801,10 +778,12 @@ async def handle_admin_block_duration_input(
pass
# Кнопки ЛС и профиль при обновлении карточки
try:
if updated.user and updated.user.telegram_id and updated.user.username:
safe_username = html.escape(updated.user.username)
if updated.user and updated.user.telegram_id:
buttons_row = []
pm_url = f"tg://resolve?domain={safe_username}"
if updated.user.username:
pm_url = f"tg://resolve?domain={updated.user.username}"
else:
pm_url = f"tg://user?id={updated.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
profile_url = f"tg://user?id={updated.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
+87 -596
View File
@@ -1,7 +1,6 @@
import logging
import re
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from typing import Optional
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -11,12 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import AdminStates
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
from app.database.crud.user import (
get_user_by_id,
get_user_by_telegram_id,
get_user_by_username,
get_referrals,
)
from app.database.crud.user import get_user_by_id
from app.database.crud.campaign import (
get_campaign_registration_by_user,
get_campaign_statistics,
@@ -41,9 +35,6 @@ from app.database.crud.server_squad import (
get_server_ids_by_uuids,
)
from app.services.subscription_service import SubscriptionService
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
logger = logging.getLogger(__name__)
@@ -1008,8 +999,7 @@ async def _render_user_subscription_overview(
subscription = profile["subscription"]
text = "📱 <b>Подписка и настройки пользователя</b>\n\n"
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
keyboard = []
@@ -1169,8 +1159,7 @@ async def show_user_transactions(
transactions = await get_user_transactions(db, user_id, limit=10)
text = f"💳 <b>Транзакции пользователя</b>\n\n"
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n"
text += f"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
if transactions:
@@ -1447,41 +1436,16 @@ async def show_user_management(
else:
sections.append(texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE)
# Display promo groups
primary_group = user.get_primary_promo_group()
if primary_group:
sections.append(
texts.t(
"ADMIN_USER_PROMO_GROUPS_PRIMARY",
"⭐ Основная: {name} (Priority: {priority})",
).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0))
)
if user.promo_group:
promo_group = user.promo_group
sections.append(
texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format(
name=primary_group.name,
server_discount=primary_group.server_discount_percent,
traffic_discount=primary_group.traffic_discount_percent,
device_discount=primary_group.device_discount_percent,
name=promo_group.name,
server_discount=promo_group.server_discount_percent,
traffic_discount=promo_group.traffic_discount_percent,
device_discount=promo_group.device_discount_percent,
)
)
# Show additional groups if any
if user.user_promo_groups and len(user.user_promo_groups) > 1:
additional_groups = [
upg.promo_group for upg in user.user_promo_groups
if upg.promo_group and upg.promo_group.id != primary_group.id
]
if additional_groups:
sections.append(
texts.t(
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL",
"Дополнительные группы:",
)
)
for group in additional_groups:
sections.append(
f"{group.name} (Priority: {getattr(group, 'priority', 0)})"
)
else:
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
@@ -1522,396 +1486,6 @@ async def show_user_management(
await callback.answer()
async def _build_user_referrals_view(
db: AsyncSession,
language: str,
user_id: int,
limit: int = 30,
) -> Optional[Tuple[str, InlineKeyboardMarkup]]:
texts = get_texts(language)
user = await get_user_by_id(db, user_id)
if not user:
return None
referrals = await get_referrals(db, user_id)
header = texts.t(
"ADMIN_USER_REFERRALS_TITLE",
"🤝 <b>Рефералы пользователя</b>",
)
summary = texts.t(
"ADMIN_USER_REFERRALS_SUMMARY",
"👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
).format(
name=user.full_name,
telegram_id=user.telegram_id,
count=len(referrals),
)
lines: List[str] = [header, summary]
if referrals:
lines.append(
texts.t(
"ADMIN_USER_REFERRALS_LIST_HEADER",
"<b>Список рефералов:</b>",
)
)
items = []
for referral in referrals[:limit]:
username_part = (
f", @{referral.username}"
if referral.username
else ""
)
referral_link = f'<a href="tg://user?id={referral.telegram_id}">{referral.full_name}</a>'
items.append(
texts.t(
"ADMIN_USER_REFERRALS_LIST_ITEM",
"{name} (ID: <code>{telegram_id}</code>{username_part})",
).format(
name=referral_link,
telegram_id=referral.telegram_id,
username_part=username_part,
)
)
lines.append("\n".join(items))
if len(referrals) > limit:
remaining = len(referrals) - limit
lines.append(
texts.t(
"ADMIN_USER_REFERRALS_LIST_TRUNCATED",
"• … и ещё {count} рефералов",
).format(count=remaining)
)
else:
lines.append(
texts.t(
"ADMIN_USER_REFERRALS_EMPTY",
"Рефералов пока нет.",
)
)
lines.append(
texts.t(
"ADMIN_USER_REFERRALS_EDIT_HINT",
"✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
)
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"ADMIN_USER_REFERRALS_EDIT_BUTTON",
"✏️ Редактировать",
),
callback_data=f"admin_user_referrals_edit_{user_id}",
)
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_user_manage_{user_id}",
)
],
]
)
return "\n\n".join(lines), keyboard
@admin_required
@error_handler
async def show_user_referrals(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
user_id = int(callback.data.split('_')[-1])
current_state = await state.get_state()
if current_state == AdminStates.editing_user_referrals:
data = await state.get_data()
preserved_data = {
key: value
for key, value in data.items()
if key not in {"editing_referrals_user_id", "referrals_message_id"}
}
await state.clear()
if preserved_data:
await state.update_data(**preserved_data)
view = await _build_user_referrals_view(db, db_user.language, user_id)
if not view:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
text, keyboard = view
await callback.message.edit_text(
text,
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def start_edit_user_referrals(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
texts = get_texts(db_user.language)
prompt = texts.t(
"ADMIN_USER_REFERRALS_EDIT_PROMPT",
(
"✏️ <b>Редактирование рефералов</b>\n\n"
"Отправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n"
"• Используйте TG ID или @username\n"
"• Значения можно указывать через запятую, пробел или с новой строки\n"
"• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n"
"Или нажмите кнопку ниже, чтобы отменить."
),
).format(
name=user.full_name,
telegram_id=user.telegram_id,
)
await state.update_data(
editing_referrals_user_id=user_id,
referrals_message_id=callback.message.message_id,
)
await callback.message.edit_text(
prompt,
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_user_referrals_{user_id}",
)
]
]
),
)
await state.set_state(AdminStates.editing_user_referrals)
await callback.answer()
@admin_required
@error_handler
async def process_edit_user_referrals(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
user_id = data.get("editing_referrals_user_id")
if not user_id:
await message.answer(
texts.t(
"ADMIN_USER_REFERRALS_STATE_LOST",
"❌ Не удалось определить пользователя. Попробуйте начать сначала.",
)
)
await state.clear()
return
raw_text = message.text.strip()
lower_text = raw_text.lower()
clear_keywords = {"0", "нет", "none", "пусто", "clear"}
clear_requested = lower_text in clear_keywords
tokens: List[str] = []
if not clear_requested:
parts = re.split(r"[,\n]+", raw_text)
for part in parts:
for token in part.split():
cleaned = token.strip()
if cleaned and cleaned not in tokens:
tokens.append(cleaned)
found_users: List[User] = []
not_found: List[str] = []
skipped_self: List[str] = []
duplicate_tokens: List[str] = []
seen_ids = set()
for token in tokens:
normalized = token.strip()
if not normalized:
continue
if normalized.startswith("@"):
normalized = normalized[1:]
user = None
if normalized.isdigit():
try:
user = await get_user_by_telegram_id(db, int(normalized))
except ValueError:
user = None
else:
user = await get_user_by_username(db, normalized)
if not user:
not_found.append(token)
continue
if user.id == user_id:
skipped_self.append(token)
continue
if user.id in seen_ids:
duplicate_tokens.append(token)
continue
seen_ids.add(user.id)
found_users.append(user)
if not found_users and not clear_requested:
error_lines = [
texts.t(
"ADMIN_USER_REFERRALS_NO_VALID",
"❌ Не удалось найти ни одного пользователя по введённым данным.",
)
]
if not_found:
error_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
"Не найдены: {values}",
).format(values=", ".join(not_found))
)
if skipped_self:
error_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
"Пропущены значения пользователя: {values}",
).format(values=", ".join(skipped_self))
)
await message.answer("\n".join(error_lines))
return
user_service = UserService()
new_referral_ids = [user.id for user in found_users] if not clear_requested else []
success, details = await user_service.update_user_referrals(
db,
user_id,
new_referral_ids,
db_user.id,
)
if not success:
await message.answer(
texts.t(
"ADMIN_USER_REFERRALS_UPDATE_ERROR",
"❌ Не удалось обновить рефералов. Попробуйте позже.",
)
)
return
response_lines = [
texts.t(
"ADMIN_USER_REFERRALS_UPDATED",
"✅ Список рефералов обновлён.",
)
]
total_referrals = details.get("total", len(new_referral_ids))
added = details.get("added", 0)
removed = details.get("removed", 0)
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_UPDATED_TOTAL",
"• Текущий список: {total}",
).format(total=total_referrals)
)
if added > 0:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_UPDATED_ADDED",
"• Добавлено: {count}",
).format(count=added)
)
if removed > 0:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_UPDATED_REMOVED",
"• Удалено: {count}",
).format(count=removed)
)
if not_found:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
"Не найдены: {values}",
).format(values=", ".join(not_found))
)
if skipped_self:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
"Пропущены значения пользователя: {values}",
).format(values=", ".join(skipped_self))
)
if duplicate_tokens:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_DUPLICATES",
"Игнорированы дубли: {values}",
).format(values=", ".join(duplicate_tokens))
)
view = await _build_user_referrals_view(db, db_user.language, user_id)
message_id = data.get("referrals_message_id")
if view and message_id:
try:
await message.bot.edit_message_text(
view[0],
chat_id=message.chat.id,
message_id=message_id,
reply_markup=view[1],
)
except TelegramBadRequest:
await message.answer(view[0], reply_markup=view[1])
elif view:
await message.answer(view[0], reply_markup=view[1])
await message.answer("\n".join(response_lines))
await state.clear()
async def _render_user_promo_group(
message: types.Message,
language: str,
@@ -1920,43 +1494,20 @@ async def _render_user_promo_group(
) -> None:
texts = get_texts(language)
# Get primary and all user groups
primary_group = user.get_primary_promo_group()
user_group_ids = [upg.promo_group_id for upg in user.user_promo_groups] if user.user_promo_groups else []
# Build current groups section
if primary_group:
current_line = texts.t(
"ADMIN_USER_PROMO_GROUPS_PRIMARY",
"⭐ Основная: {name} (Priority: {priority})",
).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0))
current_group = user.promo_group
if current_group:
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT.format(name=current_group.name)
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format(
servers=primary_group.server_discount_percent,
traffic=primary_group.traffic_discount_percent,
devices=primary_group.device_discount_percent,
servers=current_group.server_discount_percent,
traffic=current_group.traffic_discount_percent,
devices=current_group.device_discount_percent,
)
# Show additional groups if any
if len(user_group_ids) > 1:
additional_groups = [
upg.promo_group for upg in user.user_promo_groups
if upg.promo_group and upg.promo_group.id != primary_group.id
]
if additional_groups:
additional_line = "\n" + texts.t(
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL",
"Дополнительные группы:",
) + "\n"
for group in additional_groups:
additional_line += f"{group.name} (Priority: {getattr(group, 'priority', 0)})\n"
discount_line += additional_line
current_group_id = current_group.id
else:
current_line = texts.t(
"ADMIN_USER_PROMO_GROUPS_NONE",
"У пользователя нет промогрупп",
)
discount_line = ""
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE
current_group_id = None
text = (
f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n"
@@ -1970,7 +1521,7 @@ async def _render_user_promo_group(
reply_markup=get_user_promo_group_keyboard(
promo_groups,
user.id,
user_group_ids, # Pass list of all group IDs
current_group_id,
language
)
)
@@ -2008,13 +1559,6 @@ async def set_user_promo_group(
db_user: User,
db: AsyncSession
):
from app.database.crud.user_promo_group import (
has_user_promo_group,
add_user_to_promo_group,
remove_user_from_promo_group,
count_user_promo_groups
)
from app.database.crud.promo_group import get_promo_group_by_id
parts = callback.data.split('_')
user_id = int(parts[-2])
@@ -2027,52 +1571,49 @@ async def set_user_promo_group(
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
# Check if user already has this group
has_group = await has_user_promo_group(db, user_id, group_id)
if user.promo_group_id == group_id:
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ALREADY, show_alert=True)
return
if has_group:
# Remove group
# Check if it's the last group
groups_count = await count_user_promo_groups(db, user_id)
if groups_count <= 1:
await callback.answer(
texts.t(
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST",
"❌ Нельзя удалить последнюю промогруппу",
),
show_alert=True
)
return
user_service = UserService()
success, updated_user, new_group, old_group = await user_service.update_user_promo_group(
db,
user_id,
group_id
)
group = await get_promo_group_by_id(db, group_id)
await remove_user_from_promo_group(db, user_id, group_id)
await callback.answer(
texts.t(
"ADMIN_USER_PROMO_GROUP_REMOVED",
"🗑 Группа «{name}» удалена",
).format(name=group.name if group else ""),
show_alert=True
)
else:
# Add group
group = await get_promo_group_by_id(db, group_id)
if not group:
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
return
if not success or not updated_user or not new_group:
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
return
await add_user_to_promo_group(db, user_id, group_id, assigned_by="admin")
await callback.answer(
texts.t(
"ADMIN_USER_PROMO_GROUP_ADDED",
"✅ Группа «{name}» добавлена",
).format(name=group.name),
show_alert=True
)
# Refresh user data and show updated list
user = await get_user_by_id(db, user_id)
promo_groups = await get_promo_groups_with_counts(db)
await _render_user_promo_group(callback.message, db_user.language, user, promo_groups)
await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups)
await callback.answer(
texts.ADMIN_USER_PROMO_GROUP_UPDATED.format(name=new_group.name),
show_alert=True
)
try:
notification_service = AdminNotificationService(callback.bot)
reason = (
f"Назначено администратором {db_user.full_name} (ID: {db_user.telegram_id})"
)
await notification_service.send_user_promo_group_change_notification(
db,
updated_user,
old_group,
new_group,
reason=reason,
initiator=db_user,
automatic=False,
)
except Exception as notify_error:
logger.error(
"Ошибка отправки уведомления о смене промогруппы пользователя %s: %s",
updated_user.telegram_id,
notify_error,
)
@@ -2341,10 +1882,9 @@ async def show_inactive_users(
text = f"🗑️ <b>Неактивные пользователи</b>\n"
text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n"
for user in inactive_users[:10]:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link}\n"
for user in inactive_users[:10]:
text += f"👤 {user.full_name}\n"
text += f"🆔 <code>{user.telegram_id}</code>\n"
last_activity_display = (
format_time_ago(user.last_activity, db_user.language)
@@ -2446,8 +1986,7 @@ async def show_user_statistics(
campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id)
text = f"📊 <b>Статистика пользователя</b>\n\n"
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
text += f"<b>Основная информация:</b>\n"
text += f"• Дней с регистрации: {profile['registration_days']}\n"
@@ -3799,15 +3338,7 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
subscription = await create_trial_subscription(
db,
user_id,
device_limit=forced_devices,
)
subscription = await create_trial_subscription(db, user_id)
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
@@ -3836,7 +3367,10 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(db)
trial_uuid = await get_random_trial_squad_uuid(
db,
getattr(settings, "TRIAL_SQUAD_UUID", None),
)
if trial_uuid:
trial_squads = [trial_uuid]
except Exception as error:
@@ -3845,21 +3379,15 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
admin_id,
error,
)
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
device_limit = settings.DEFAULT_DEVICE_LIMIT
if forced_devices is not None:
device_limit = forced_devices
if getattr(settings, "TRIAL_SQUAD_UUID", None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=days,
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=device_limit,
device_limit=settings.DEFAULT_DEVICE_LIMIT,
connected_squads=trial_squads,
update_server_counters=True,
)
@@ -4063,13 +3591,10 @@ async def admin_buy_subscription(
])
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
servers_count = len(subscription.connected_squads or [])
text += f"📶 Трафик: {traffic_text}\n"
text += f"📱 Устройства: {devices_limit}\n"
@@ -4155,15 +3680,12 @@ async def admin_buy_subscription_confirm(
return
text = f"💳 <b>Подтверждение покупки подписки</b>\n\n"
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
text += f"📅 Период подписки: {period_days} дней\n"
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
servers_count = len(subscription.connected_squads or [])
text += f"📶 Трафик: {traffic_text}\n"
text += f"📱 Устройства: {devices_limit}\n"
@@ -4310,54 +3832,40 @@ async def admin_buy_subscription_execute(
from app.external.remnawave_api import UserStatus, TrafficLimitStrategy
remnawave_service = RemnaWaveService()
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
if target_user.remnawave_uuid:
async with remnawave_service.get_api_client() as api:
update_kwargs = dict(
remnawave_user = await api.update_user(
uuid=target_user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=subscription.connected_squads
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
remnawave_user = await api.update_user(**update_kwargs)
else:
username = settings.format_remnawave_username(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id,
)
username = f"user_{target_user.telegram_id}"
async with remnawave_service.get_api_client() as api:
create_kwargs = dict(
remnawave_user = await api.create_user(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=target_user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=subscription.connected_squads
)
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
remnawave_user = await api.create_user(**create_kwargs)
if remnawave_user and hasattr(remnawave_user, 'uuid'):
target_user.remnawave_uuid = remnawave_user.uuid
@@ -4374,10 +3882,9 @@ async def admin_buy_subscription_execute(
else:
message = "❌ Ошибка: у пользователя нет существующей подписки"
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
await callback.message.edit_text(
f"{message}\n\n"
f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
f"💰 Списано: {settings.format_price(price_kopeks)}\n"
f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -4385,8 +3892,7 @@ async def admin_buy_subscription_execute(
text="⬅️ Назад к подписке",
callback_data=f"admin_user_subscription_{user_id}"
)]
]),
parse_mode="HTML"
])
)
try:
@@ -4598,12 +4104,12 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_user_promo_group,
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_") & ~F.data.contains("_toggle_")
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_")
)
dp.callback_query.register(
set_user_promo_group,
F.data.startswith("admin_user_promo_group_toggle_")
F.data.startswith("admin_user_promo_group_set_")
)
dp.callback_query.register(
@@ -4616,21 +4122,6 @@ def register_handlers(dp: Dispatcher):
AdminStates.editing_user_balance
)
dp.callback_query.register(
show_user_referrals,
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
)
dp.callback_query.register(
start_edit_user_referrals,
F.data.startswith("admin_user_referrals_edit_")
)
dp.message.register(
process_edit_user_referrals,
AdminStates.editing_user_referrals
)
dp.callback_query.register(
start_send_user_message,
F.data.startswith("admin_user_send_message_")
-93
View File
@@ -1,5 +1,4 @@
import logging
import re
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,92 +18,6 @@ from app.database.crud.welcome_text import (
logger = logging.getLogger(__name__)
def validate_html_tags(text: str) -> tuple[bool, str]:
"""
Проверяет HTML-теги в тексте на соответствие требованиям Telegram API.
Args:
text: Текст для проверки
Returns:
Кортеж из (валидно ли, сообщение об ошибке или None)
"""
# Поддерживаемые теги в parse_mode="HTML" для Telegram API
allowed_tags = {
'b', 'strong', # жирный
'i', 'em', # курсив
'u', 'ins', # подчеркнуто
's', 'strike', 'del', # зачеркнуто
'code', # моноширинный для коротких фрагментов
'pre', # моноширинный блок кода
'a' # ссылки
}
# Убираем плейсхолдеры из строки перед проверкой тегов
# Плейсхолдеры имеют формат {ключ}, и не являются тегами
placeholder_pattern = r'\{[^{}]+\}'
clean_text = re.sub(placeholder_pattern, '', text)
# Находим все открывающие и закрывающие теги
tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>'
tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)]
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
tag_lower = tag.lower()
# Проверяем, является ли тег поддерживаемым
if tag_lower not in allowed_tags:
return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}"
# Проверяем атрибуты для тега <a>
if tag_lower == 'a':
if closing:
continue # Для закрывающего тега не нужно проверять атрибуты
if not attrs:
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
# Проверяем, что есть атрибут href
if 'href=' not in attrs.lower():
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
# Проверяем формат URL
href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE)
if href_match:
url = href_match.group(1)
# Проверяем, что URL начинается с поддерживаемой схемы
if not re.match(r'^https?://|^tg://', url, re.IGNORECASE):
return False, f"URL в теге <a> должен начинаться с http://, https:// или tg://. Найдено: {url}"
else:
return False, "Не удалось извлечь URL из атрибута href тега <a>"
# Проверяем парность тегов с использованием стека
stack = []
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
tag_lower = tag.lower()
if tag_lower not in allowed_tags:
continue
if closing:
# Это закрывающий тег
if not stack:
return False, f"Лишний закрывающий тег: </{tag}>"
last_opening_tag = stack.pop()
if last_opening_tag.lower() != tag_lower:
return False, f"Тег </{tag}> не соответствует открывающему тегу <{last_opening_tag}>"
else:
# Это открывающий тег
stack.append(tag)
# Если остались незакрытые теги
if stack:
unclosed_tags = ", ".join([f"<{tag}>" for tag in stack])
return False, f"Незакрытые теги: {unclosed_tags}"
return True, None
def get_telegram_formatting_info() -> str:
return """
📝 <b>Поддерживаемые теги форматирования:</b>
@@ -289,12 +202,6 @@ async def process_welcome_text_edit(
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
return
# Проверяем HTML-теги на валидность
is_valid, error_msg = validate_html_tags(new_text)
if not is_valid:
await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}")
return
success = await set_welcome_text(db, new_text, db_user.id)
if success:
+1 -1
View File
@@ -67,7 +67,7 @@ async def start_cryptobot_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
+1 -1
View File
@@ -55,7 +55,7 @@ async def start_heleket_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_buttons = get_quick_amount_buttons(db_user.language)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
+17 -183
View File
@@ -8,8 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import BalanceStates
from app.database.crud.user import add_user_balance
from app.utils.price_display import calculate_user_price, format_price_button
from app.utils.pricing_utils import format_period_description
from app.database.crud.transaction import (
get_user_transactions, get_user_transactions_count,
create_transaction
@@ -29,61 +27,34 @@ logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
Returns:
List of button rows for inline keyboard
"""
def get_quick_amount_buttons(language: str) -> list:
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
return []
from app.localization.texts import get_texts
texts = get_texts(language)
buttons = []
periods = settings.get_available_subscription_periods()
periods = periods[:6] # Limit to 6 periods
periods = periods[:6]
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
base_price_kopeks = getattr(settings, price_attr)
# Calculate price with user's promo group discount using unified system
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
callback_data = f"quick_amount_{price_info.final_price}"
# Format button text with discount display
period_label = f"{period} дней"
# For balance buttons, use simpler format without emoji and period label prefix
if price_info.has_discount:
button_text = (
f"{texts.format_price(price_info.base_price)}"
f"{texts.format_price(price_info.final_price)} "
f"(-{price_info.discount_percent}%) • {period_label}"
)
else:
button_text = f"{texts.format_price(price_info.final_price)}{period_label}"
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
callback_data = f"quick_amount_{price_kopeks}"
buttons.append(
types.InlineKeyboardButton(
text=button_text,
text=f"{price_rubles} ₽ ({period} дней)",
callback_data=callback_data
)
)
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i:i + 2])
return keyboard_rows
@@ -209,112 +180,25 @@ async def handle_balance_history_pagination(
async def show_payment_methods(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
from app.utils.payment_utils import get_payment_methods_text
from app.database.crud.subscription import get_subscription_by_user_id
from app.utils.pricing_utils import calculate_months_from_days, apply_percentage_discount
from app.config import settings
from app.services.subscription_service import SubscriptionService
texts = get_texts(db_user.language)
payment_text = get_payment_methods_text(db_user.language)
# Добавляем информацию о текущем тарифе пользователя
subscription = await get_subscription_by_user_id(db, db_user.id)
tariff_info = ""
if subscription and not subscription.is_trial:
# Рассчитываем приблизительную стоимость продления на 30 дней
duration_days = 30 # Берем для примера 30 дней
current_traffic = subscription.traffic_limit_gb
current_connected_squads = subscription.connected_squads or []
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
try:
# Получаем цены для текущих параметров
from app.config import PERIOD_PRICES
base_price_original = PERIOD_PRICES.get(duration_days, 0)
period_discount_percent = db_user.get_promo_discount("period", duration_days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
# Рассчитываем стоимость серверов
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
current_connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount("servers", duration_days)
total_servers_price = 0
for server_price in per_server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
total_servers_price += discounted_per_month
# Рассчитываем стоимость трафика
traffic_price_per_month = settings.get_traffic_price(current_traffic)
traffic_discount_percent = db_user.get_promo_discount("traffic", duration_days)
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
# Рассчитываем стоимость устройств
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount("devices", duration_days)
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Общая стоимость
months_in_period = calculate_months_from_days(duration_days)
total_price = (
base_price +
total_servers_price * months_in_period +
traffic_discounted_per_month * months_in_period +
devices_discounted_per_month * months_in_period
)
traffic_value = current_traffic or 0
if traffic_value <= 0:
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
else:
traffic_display = texts.format_traffic(traffic_value)
current_tariff_desc = (
f"📱 Подписка: {len(current_connected_squads)} серверов, "
f"{traffic_display}, {current_device_limit} устр."
)
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
except Exception as e:
logger.warning(f"Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}")
tariff_info = ""
full_text = payment_text + tariff_info
keyboard = get_payment_methods_keyboard(0, db_user.language)
try:
await callback.message.edit_text(
full_text,
payment_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.edit_caption(
full_text,
payment_text,
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -324,7 +208,7 @@ async def show_payment_methods(
except TelegramBadRequest:
pass
await callback.message.answer(
full_text,
payment_text,
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -395,10 +279,6 @@ async def handle_successful_topup_with_cart(
success_text = (
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
f"🛒 У вас есть сохраненная корзина подписки\n"
f"Стоимость: {texts.format_price(total_price)}\n\n"
f"Хотите продолжить оформление?"
@@ -532,14 +412,6 @@ async def process_topup_amount(
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
@@ -651,14 +523,6 @@ async def handle_quick_amount_selection(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
@@ -746,13 +610,6 @@ async def handle_topup_amount_callback(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
@@ -774,13 +631,6 @@ async def handle_topup_amount_callback(
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
@@ -864,16 +714,6 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("pal24_method_"),
)
from .platega import start_platega_payment, handle_platega_method_selection
dp.callback_query.register(
start_platega_payment,
F.data == "topup_platega"
)
dp.callback_query.register(
handle_platega_method_selection,
F.data.startswith("platega_method_"),
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
@@ -942,12 +782,6 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("check_pal24_")
)
from .platega import check_platega_payment_status
dp.callback_query.register(
check_platega_payment_status,
F.data.startswith("check_platega_")
)
dp.callback_query.register(
handle_payment_methods_unavailable,
F.data == "payment_methods_unavailable"
@@ -962,4 +796,4 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(
handle_topup_amount_callback,
F.data.startswith("topup_amount|")
)
)
+1 -1
View File
@@ -48,7 +48,7 @@ async def start_mulenpay_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
+7 -121
View File
@@ -1,7 +1,5 @@
import html
import logging
from typing import Any, Optional
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
@@ -266,7 +264,7 @@ async def start_pal24_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -425,77 +423,8 @@ async def check_pal24_payment_status(
if not isinstance(links_meta, dict):
links_meta = {}
links_info = status_info.get("links") or {}
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
stack: list[Any] = [source]
while stack:
current = stack.pop()
if isinstance(current, dict):
for key in keys:
value = current.get(key)
if value:
return str(value)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return None
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
remote_data = status_info.get("remote_data")
transfer_keys = (
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_keys = (
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
extra_sbp_link = (
_extract_link(raw_response, transfer_keys)
if raw_response
else None
)
if not extra_sbp_link and remote_data:
extra_sbp_link = _extract_link(remote_data, transfer_keys)
extra_card_link = (
_extract_link(raw_response, card_keys)
if raw_response
else None
)
if not extra_card_link and remote_data:
extra_card_link = _extract_link(remote_data, card_keys)
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or extra_sbp_link
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
or extra_card_link
)
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
@@ -530,53 +459,10 @@ async def check_pal24_payment_status(
db_user = getattr(callback, 'db_user', None)
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
pay_rows: list[list[types.InlineKeyboardButton]] = []
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_link and settings.is_pal24_sbp_button_visible():
pay_rows.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_link,
)
]
)
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_link and settings.is_pal24_card_button_visible():
if not pay_rows or pay_rows[-1][0].url != card_link:
pay_rows.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_link,
)
]
)
keyboard_rows = pay_rows + [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_pal24_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.answer()
try:
-333
View File
@@ -1,333 +0,0 @@
"""Handlers for Platega balance interactions."""
import logging
from typing import List
from aiogram import types
from aiogram.fsm.context import FSMContext
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__)
def _get_active_methods() -> List[int]:
methods = settings.get_platega_active_methods()
return [code for code in methods if code in {2, 10, 11, 12, 13}]
async def _prompt_amount(
message: types.Message,
db_user: User,
state: FSMContext,
method_code: int,
) -> None:
texts = get_texts(db_user.language)
method_name = settings.get_platega_method_display_title(method_code)
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
max_amount_label = (
settings.format_price(max_amount_kopeks)
if max_amount_kopeks and max_amount_kopeks > 0
else ""
)
default_prompt_body = (
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
if max_amount_kopeks and max_amount_kopeks > 0
else "Введите сумму для пополнения от {min_amount}.\n"
)
prompt_template = texts.t(
"PLATEGA_TOPUP_PROMPT",
(
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
f"{default_prompt_body}"
"Оплата происходит через Platega."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await message.edit_text(
prompt_template.format(
method_name=method_name,
min_amount=min_amount_label,
max_amount=max_amount_label,
),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="platega", platega_method=method_code)
@error_handler
async def start_platega_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await callback.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
),
show_alert=True,
)
return
active_methods = _get_active_methods()
if not active_methods:
await callback.answer(
texts.t(
"PLATEGA_METHODS_NOT_CONFIGURED",
"⚠️ На стороне Platega нет доступных методов оплаты",
),
show_alert=True,
)
return
await state.update_data(payment_method="platega")
if len(active_methods) == 1:
await _prompt_amount(callback.message, db_user, state, active_methods[0])
await callback.answer()
return
method_buttons: list[list[types.InlineKeyboardButton]] = []
for method_code in active_methods:
label = settings.get_platega_method_display_title(method_code)
method_buttons.append(
[
types.InlineKeyboardButton(
text=label,
callback_data=f"platega_method_{method_code}",
)
]
)
method_buttons.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
)
await callback.message.edit_text(
texts.t(
"PLATEGA_SELECT_PAYMENT_METHOD",
"Выберите способ оплаты Platega:",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
await state.set_state(BalanceStates.waiting_for_platega_method)
await callback.answer()
@error_handler
async def handle_platega_method_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
try:
method_code = int(callback.data.rsplit("_", 1)[-1])
except ValueError:
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
return
if method_code not in _get_active_methods():
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
return
await _prompt_amount(callback.message, db_user, state, method_code)
await callback.answer()
@error_handler
async def process_platega_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await message.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
)
)
return
data = await state.get_data()
method_code = int(data.get("platega_method", 0))
if method_code not in _get_active_methods():
await message.answer(
texts.t(
"PLATEGA_METHOD_SELECTION_REQUIRED",
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
)
)
await state.set_state(BalanceStates.waiting_for_platega_method)
return
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_LOW",
"Минимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_HIGH",
"Максимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
)
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_platega_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
payment_method_code=method_code,
)
except Exception as error:
logger.exception("Ошибка создания платежа Platega: %s", error)
payment_result = None
if not payment_result or not payment_result.get("redirect_url"):
await message.answer(
texts.t(
"PLATEGA_PAYMENT_ERROR",
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
redirect_url = payment_result.get("redirect_url")
local_payment_id = payment_result.get("local_payment_id")
transaction_id = payment_result.get("transaction_id")
method_title = settings.get_platega_method_display_title(method_code)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"PLATEGA_PAY_BUTTON",
"💳 Оплатить через {method}",
).format(method=method_title),
url=redirect_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_platega_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
instructions_template = texts.t(
"PLATEGA_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через Platega ({method})</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID транзакции: {transaction}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку «Оплатить»\n"
"2. Следуйте подсказкам платёжной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
await message.answer(
instructions_template.format(
method=method_title,
amount=settings.format_price(amount_kopeks),
transaction=transaction_id or local_payment_id,
support=settings.get_support_contact_display_html(),
),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
@error_handler
async def check_platega_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except ValueError:
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
payment_service = PaymentService(callback.bot)
try:
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
except Exception as error:
logger.exception("Ошибка проверки статуса Platega: %s", error)
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
return
if not status_info:
await callback.answer("⚠️ Платёж не найден", show_alert=True)
return
payment = status_info.get("payment")
status = status_info.get("status")
is_paid = status_info.get("is_paid")
language = "ru"
user = getattr(payment, "user", None)
if user and getattr(user, "language", None):
language = user.language
texts = get_texts(language)
if is_paid:
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
else:
await callback.answer(
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
show_alert=True,
)
+1 -1
View File
@@ -42,7 +42,7 @@ async def start_stars_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
+1 -1
View File
@@ -45,7 +45,7 @@ async def start_wata_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
+2 -2
View File
@@ -48,7 +48,7 @@ async def start_yookassa_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -98,7 +98,7 @@ async def start_yookassa_sbp_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
+4 -6
View File
@@ -38,7 +38,6 @@ from app.utils.promo_offer import (
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.services.faq_service import FaqService
from app.utils.timezone import format_local_datetime
from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
@@ -953,8 +952,7 @@ def _get_subscription_status(user: User, texts) -> str:
current_time = datetime.utcnow()
actual_status = (subscription.actual_status or "").lower()
end_date = getattr(subscription, "end_date", None)
end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
days_left = 0
if subscription.end_date > current_time:
@@ -970,10 +968,10 @@ def _get_subscription_status(user: User, texts) -> str:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date_text or "")
).format(end_date=end_date_text)
if actual_status == "trial":
if days_left > 1 and end_date_text:
if days_left > 1:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
@@ -992,7 +990,7 @@ def _get_subscription_status(user: User, texts) -> str:
)
if actual_status == "active":
if days_left > 7 and end_date_text:
if days_left > 7:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
+26 -47
View File
@@ -1,8 +1,9 @@
import logging
from aiogram import Dispatcher, types, F, Bot
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import PromoCodeStates
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
@@ -31,45 +32,6 @@ async def show_promocode_menu(
await callback.answer()
async def activate_promocode_for_registration(
db: AsyncSession,
user_id: int,
code: str,
bot: Bot = None
) -> dict:
"""
Активирует промокод для пользователя во время регистрации.
Возвращает результат активации без отправки сообщений.
"""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, user_id, code)
if result["success"]:
logger.info(f"✅ Пользователь {user_id} активировал промокод {code} при регистрации")
# Отправляем уведомление админу, если бот доступен
if bot:
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user:
notification_service = AdminNotificationService(bot)
await notification_service.send_promocode_activation_notification(
db,
user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
return result
@error_handler
async def process_promocode(
message: types.Message,
@@ -78,9 +40,9 @@ async def process_promocode(
db: AsyncSession
):
texts = get_texts(db_user.language)
code = message.text.strip()
if not code:
await message.answer(
texts.t(
@@ -90,14 +52,31 @@ async def process_promocode(
reply_markup=get_back_keyboard(db_user.language)
)
return
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, db_user.id, code)
if result["success"]:
await message.answer(
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_promocode_activation_notification(
db,
db_user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
@@ -106,13 +85,13 @@ async def process_promocode(
"already_used_by_user": texts.PROMOCODE_USED,
"server_error": texts.ERROR
}
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
await message.answer(
error_text,
reply_markup=get_back_keyboard(db_user.language)
)
await state.clear()
+76 -526
View File
@@ -16,10 +16,7 @@ from app.services.payment_service import PaymentService
from app.services.subscription_purchase_service import SubscriptionPurchaseService
from app.utils.decorators import error_handler
from app.states import SubscriptionStates
from app.utils.subscription_utils import (
get_display_subscription_link,
resolve_simple_subscription_device_limit,
)
from app.utils.subscription_utils import get_display_subscription_link
from app.utils.pricing_utils import compute_simple_subscription_price
logger = logging.getLogger(__name__)
@@ -38,17 +35,15 @@ async def start_simple_subscription_purchase(
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Проверяем, есть ли у пользователя подписка
# Проверяем, есть ли у пользователя подписка (информируем, но не блокируем покупку)
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
device_limit = resolve_simple_subscription_device_limit()
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
"device_limit": device_limit,
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
@@ -99,115 +94,63 @@ async def start_simple_subscription_purchase(
can_pay_from_balance,
)
# Проверяем, является ли у пользователя текущая подписка активной платной подпиской
has_active_paid_subscription = False
trial_notice = ""
if current_subscription:
if not getattr(current_subscription, "is_trial", False) and current_subscription.is_active:
# Это платная активная подписка - требуем подтверждение
has_active_paid_subscription = True
elif getattr(current_subscription, "is_trial", False):
# Это тестовая подписка
try:
days_left = max(0, (current_subscription.end_date - datetime.utcnow()).days)
except Exception:
days_left = 0
key = "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE" if current_subscription.is_active else "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL"
trial_notice = texts.t(
key,
"ℹ️ У вас уже есть триальная подписка. Она истекает через {days} дн.",
).format(days=days_left)
if current_subscription and getattr(current_subscription, "is_trial", False):
try:
days_left = max(0, (current_subscription.end_date - datetime.utcnow()).days)
except Exception:
days_left = 0
key = "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE" if current_subscription.is_active else "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL"
trial_notice = texts.t(
key,
"ℹ️ У вас уже есть триальная подписка. Она истекает через {days} дн.",
).format(days=days_left)
server_label = _get_simple_subscription_server_label(
texts,
subscription_params,
resolved_squad_uuid,
)
show_devices = settings.is_devices_selection_enabled()
message_lines = [
"⚡ <b>Простая покупка подписки</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}",
"",
])
# Если у пользователя уже есть активная платная подписка, требуем подтверждение
if has_active_paid_subscription:
# У пользователя уже есть активная платная подписка
message_lines.append(
"⚠️ У вас уже есть активная платная подписка. "
"Покупка простой подписки изменит параметры вашей текущей подписки. "
"Требуется подтверждение."
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {server_label}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
+ (
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
if can_pay_from_balance
else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
)
message_text = "\n".join(message_lines)
)
# Клавиатура с подтверждением
keyboard_rows = [
[types.InlineKeyboardButton(
text="✅ Подтвердить покупку",
callback_data="simple_subscription_confirm_purchase"
)],
[types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)]
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
else:
# У пользователя нет активной платной подписки (или есть только пробная)
# Показываем стандартный выбор метода оплаты
if can_pay_from_balance:
message_lines.append(
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
if trial_notice:
message_text = f"{trial_notice}\n\n{message_text}"
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
if can_pay_from_balance:
keyboard_rows.append([
types.InlineKeyboardButton(
text="✅ Оплатить с баланса",
callback_data="simple_subscription_pay_with_balance",
)
else:
message_lines.append(
"Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
)
message_text = "\n".join(message_lines)
if trial_notice:
message_text = f"{trial_notice}\n\n{message_text}"
])
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
if can_pay_from_balance:
keyboard_rows.append([
types.InlineKeyboardButton(
text="✅ Оплатить с баланса",
callback_data="simple_subscription_pay_with_balance",
)
])
keyboard_rows.extend(methods_keyboard.inline_keyboard)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
keyboard_rows.extend(methods_keyboard.inline_keyboard)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
# Устанавливаем соответствующее состояние
if has_active_paid_subscription:
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_confirmation)
else:
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await callback.answer()
@@ -375,15 +318,6 @@ async def handle_simple_subscription_pay_with_balance(
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Проверяем, имеет ли пользователь активную платную подписку
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
if current_subscription and not getattr(current_subscription, "is_trial", False) and current_subscription.is_active:
# У пользователя есть активная платная подписка - требуем подтверждение
await callback.answer("⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку.", show_alert=True)
return
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
db,
state,
@@ -441,10 +375,7 @@ async def handle_simple_subscription_pay_with_balance(
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
if existing_subscription:
# Если подписка уже существует (платная или тестовая), продлеваем её
# Сохраняем информацию о текущей подписке, особенно является ли она пробной
was_trial = getattr(existing_subscription, "is_trial", False)
# Если подписка уже существует, продлеваем её
subscription = await extend_subscription(
db=db,
subscription=existing_subscription,
@@ -453,16 +384,6 @@ async def handle_simple_subscription_pay_with_balance(
# Обновляем параметры подписки
subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"]
subscription.device_limit = subscription_params["device_limit"]
# Если текущая подписка была пробной, и мы обновляем её
# нужно изменить статус подписки
if was_trial:
from app.database.models import SubscriptionStatus
# Переводим подписку из пробной в активную платную
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.is_trial = False
# Устанавливаем новый выбранный сквад
if resolved_squad_uuid:
subscription.connected_squads = [resolved_squad_uuid]
@@ -512,28 +433,16 @@ async def handle_simple_subscription_pay_with_balance(
subscription_params,
resolved_squad_uuid,
)
show_devices = settings.is_devices_selection_enabled()
success_lines = [
"✅ <b>Подписка успешно активирована!</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
success_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}",
"",
"🔗 Для подключения перейдите в раздел 'Подключиться'",
])
success_message = "\n".join(success_lines)
success_message = (
f"✅ <b>Подписка успешно активирована!</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {server_label}\n\n"
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}\n\n"
f"🔗 Для подключения перейдите в раздел 'Подключиться'"
)
connect_mode = settings.CONNECT_BUTTON_MODE
subscription_link = get_display_subscription_link(subscription)
@@ -710,31 +619,19 @@ async def handle_simple_subscription_other_payment_methods(
subscription_params,
resolved_squad_uuid,
)
show_devices = settings.is_devices_selection_enabled()
message_lines = [
"💳 <b>Оплата подписки</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
"",
(
message_text = (
f"💳 <b>Оплата подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {server_label}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
+ (
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
if can_pay_from_balance
else "Выберите подходящий способ оплаты:"
),
])
message_text = "\n".join(message_lines)
)
)
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
@@ -776,15 +673,6 @@ async def handle_simple_subscription_payment_method(
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Проверяем, имеет ли пользователь активную платную подписку
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
if current_subscription and not getattr(current_subscription, "is_trial", False) and current_subscription.is_active:
# У пользователя есть активная платная подписка - показываем сообщение
await callback.answer("⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку через главное меню.", show_alert=True)
return
payment_method = callback.data.replace("simple_subscription_", "")
try:
@@ -966,25 +854,14 @@ async def handle_simple_subscription_payment_method(
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
# Подготавливаем текст сообщения
show_devices = settings.is_devices_selection_enabled()
message_lines = [
"💳 <b>Оплата подписки через YooKassa</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"💰 Сумма: {settings.format_price(price_kopeks)}",
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
"",
])
message_text = "\n".join(message_lines)
message_text = (
f"💳 <b>Оплата подписки через YooKassa</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"💰 Сумма: {settings.format_price(price_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
)
# Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url:
@@ -1655,18 +1532,8 @@ async def check_simple_pal24_payment_status(
if not isinstance(links_meta, dict):
links_meta = {}
links_info = status_info.get("links") or {}
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
)
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
@@ -1699,45 +1566,8 @@ async def check_simple_pal24_payment_status(
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}",
]
pay_rows: list[list[types.InlineKeyboardButton]] = []
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_link and settings.is_pal24_sbp_button_visible():
pay_rows.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_link,
)
]
)
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_link and settings.is_pal24_card_button_visible():
if not pay_rows or pay_rows[-1][0].url != card_link:
pay_rows.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_link,
)
]
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=pay_rows
+ [
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
@@ -2063,281 +1893,6 @@ async def check_simple_wata_payment_status(
parse_mode="HTML",
)
@error_handler
async def confirm_simple_subscription_purchase(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Обрабатывает подтверждение простой покупки подписки при наличии активной платной подписки."""
texts = get_texts(db_user.language)
data = await state.get_data()
subscription_params = data.get("subscription_params", {})
if not subscription_params:
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
db,
state,
subscription_params,
user_id=db_user.id,
state_data=data,
)
# Рассчитываем цену подписки
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=resolved_squad_uuid,
)
total_required = price_kopeks
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s",
db_user.id,
subscription_params["period_days"],
price_breakdown.get("base_price", 0),
price_breakdown.get("traffic_price", 0),
price_breakdown.get("devices_price", 0),
price_breakdown.get("servers_price", 0),
price_breakdown.get("total_discount", 0),
total_required,
getattr(db_user, "balance_kopeks", 0),
)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
if user_balance_kopeks < total_required:
await callback.answer("❌ Недостаточно средств на балансе для оплаты подписки", show_alert=True)
return
try:
# Списываем средства с баланса пользователя
from app.database.crud.user import subtract_user_balance
success = await subtract_user_balance(
db,
db_user,
price_kopeks,
f"Оплата подписки на {subscription_params['period_days']} дней",
consume_promo_offer=False,
)
if not success:
await callback.answer("❌ Ошибка списания средств с баланса", show_alert=True)
return
# Проверяем, есть ли у пользователя уже подписка
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
if existing_subscription:
# Если подписка уже существует, продлеваем её
# Сохраняем информацию о текущей подписке, особенно является ли она пробной
was_trial = getattr(existing_subscription, "is_trial", False)
subscription = await extend_subscription(
db=db,
subscription=existing_subscription,
days=subscription_params["period_days"]
)
# Обновляем параметры подписки
subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"]
subscription.device_limit = subscription_params["device_limit"]
# Если текущая подписка была пробной, и мы обновляем её
# нужно изменить статус подписки
if was_trial:
from app.database.models import SubscriptionStatus
# Переводим подписку из пробной в активную платную
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.is_trial = False
# Устанавливаем новый выбранный сквад
if resolved_squad_uuid:
subscription.connected_squads = [resolved_squad_uuid]
await db.commit()
await db.refresh(subscription)
else:
# Если подписки нет, создаём новую
from app.database.crud.subscription import create_paid_subscription
subscription = await create_paid_subscription(
db=db,
user_id=db_user.id,
duration_days=subscription_params["period_days"],
traffic_limit_gb=subscription_params["traffic_limit_gb"],
device_limit=subscription_params["device_limit"],
connected_squads=[resolved_squad_uuid] if resolved_squad_uuid else [],
update_server_counters=True,
)
if not subscription:
# Возвращаем средства на баланс в случае ошибки
from app.services.payment_service import add_user_balance
await add_user_balance(
db,
db_user.id,
price_kopeks,
f"Возврат средств за неудавшуюся подписку на {subscription_params['period_days']} дней",
)
await callback.answer("❌ Ошибка создания подписки. Средства возвращены на баланс.", show_alert=True)
return
# Обновляем баланс пользователя
await db.refresh(db_user)
# Обновляем или создаём ссылку подписки в RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
if remnawave_user:
await db.refresh(subscription)
except Exception as sync_error:
logger.error(f"Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}", exc_info=True)
# Отправляем уведомление об успешной покупке
server_label = _get_simple_subscription_server_label(
texts,
subscription_params,
resolved_squad_uuid,
)
show_devices = settings.is_devices_selection_enabled()
success_lines = [
"✅ <b>Подписка успешно активирована!</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
success_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}",
"",
"🔗 Для подключения перейдите в раздел 'Подключиться'",
])
success_message = "\n".join(success_lines)
connect_mode = settings.CONNECT_BUTTON_MODE
subscription_link = get_display_subscription_link(subscription)
connect_button_text = texts.t("CONNECT_BUTTON", "🔗 Подключиться")
def _fallback_connect_button() -> types.InlineKeyboardButton:
return types.InlineKeyboardButton(
text=connect_button_text,
callback_data="subscription_connect",
)
if connect_mode == "miniapp_subscription":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
web_app=types.WebAppInfo(url=subscription_link),
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "miniapp_custom":
custom_url = settings.MINIAPP_CUSTOM_URL
if custom_url:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
web_app=types.WebAppInfo(url=custom_url),
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "link":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
url=subscription_link,
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "happ_cryptolink":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
callback_data="open_subscription_link",
)
]
else:
connect_row = [_fallback_connect_button()]
else:
connect_row = [_fallback_connect_button()]
keyboard_rows = [connect_row]
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard_rows.append(happ_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
success_message,
reply_markup=keyboard,
parse_mode="HTML"
)
# Отправляем уведомление админам
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db,
db_user,
subscription,
None, # transaction
subscription_params["period_days"],
False, # was_trial_conversion
amount_kopeks=price_kopeks,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления админам о покупке: {e}")
await state.clear()
await callback.answer()
logger.info(f"Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks/100}")
except Exception as error:
logger.error(
"Ошибка подтверждения простой подписки с баланса для пользователя %s: %s",
db_user.id,
error,
exc_info=True,
)
await callback.answer(
"❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.",
show_alert=True,
)
await state.clear()
def register_simple_subscription_handlers(dp):
"""Регистрирует обработчики простой покупки подписки."""
@@ -2346,11 +1901,6 @@ def register_simple_subscription_handlers(dp):
F.data == "simple_subscription_purchase"
)
dp.callback_query.register(
confirm_simple_subscription_purchase,
F.data == "simple_subscription_confirm_purchase"
)
dp.callback_query.register(
handle_simple_subscription_pay_with_balance,
F.data == "simple_subscription_pay_with_balance"
-4
View File
@@ -130,10 +130,6 @@ async def handle_successful_payment(
"⭐ Потрачено звезд: {stars_spent}\n"
"💰 Зачислено на баланс: {amount}\n"
"🆔 ID транзакции: {transaction_id}...\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
+81 -186
View File
@@ -37,7 +37,6 @@ from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.utils.timezone import format_local_datetime
from app.database.crud.user_message import get_random_active_message
from app.database.crud.subscription import decrement_subscription_server_counts
@@ -45,17 +44,6 @@ from app.database.crud.subscription import decrement_subscription_server_counts
logger = logging.getLogger(__name__)
def _calculate_subscription_flags(subscription):
if not subscription:
return False, False
actual_status = getattr(subscription, "actual_status", None)
has_active_subscription = actual_status in {"active", "trial"}
subscription_is_active = bool(getattr(subscription, "is_active", False))
return has_active_subscription, subscription_is_active
async def _apply_campaign_bonus_if_needed(
db: AsyncSession,
user,
@@ -100,15 +88,15 @@ async def handle_potential_referral_code(
db: AsyncSession
):
current_state = await state.get_state()
logger.info(f"🔍 REFERRAL/PROMO CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
if current_state not in [
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state,
None
None
]:
return False
user = await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status == UserStatus.ACTIVE.value:
return False
@@ -125,73 +113,37 @@ async def handle_potential_referral_code(
if len(potential_code) < 4 or len(potential_code) > 20:
return False
# Сначала проверяем реферальный код
referrer = await get_user_by_referral_code(db, potential_code)
if referrer:
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "Реферальный код принят!"))
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после ввода реферального кода")
else:
await complete_registration(message, state, db)
if not referrer:
await message.answer(texts.t(
"REFERRAL_CODE_INVALID_HELP",
"❌ Неверный реферальный код.\n\n"
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
))
return True
# Если реферальный код не найден, проверяем промокод
from app.database.crud.promocode import check_promocode_validity
promocode_check = await check_promocode_validity(db, potential_code)
if promocode_check["valid"]:
# Промокод валиден - сохраняем его в state для активации после создания пользователя
data['promocode'] = potential_code
await state.set_data(data)
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
texts.t(
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
"✅ Промокод принят! Он будет активирован после завершения регистрации."
)
rules_text,
reply_markup=get_rules_keyboard(language)
)
logger.info(f"✅ Промокод {potential_code} сохранен для активации для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после принятия промокода")
else:
await complete_registration(message, state, db)
return True
# Ни реферальный код, ни промокод не найдены
await message.answer(texts.t(
"REFERRAL_OR_PROMO_CODE_INVALID_HELP",
"❌ Неверный реферальный код или промокод.\n\n"
"💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n"
"⏭️ Для продолжения регистрации без кода используйте команду /start",
))
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после ввода реферального кода")
else:
await complete_registration(message, state, db)
return True
@@ -272,11 +224,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
data = await state.get_data() or {}
had_pending_payload = "pending_start_payload" in data
pending_start_payload = data.pop("pending_start_payload", None)
had_campaign_notification_flag = "campaign_notification_sent" in data
campaign_notification_sent = data.pop("campaign_notification_sent", False)
state_needs_update = had_pending_payload or had_campaign_notification_flag
referral_code = None
campaign = None
@@ -292,7 +240,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
pending_start_payload,
)
if state_needs_update:
if pending_start_payload is not None:
await state.set_data(data)
if start_parameter:
@@ -318,7 +266,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
if campaign and not campaign_notification_sent:
if campaign:
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_campaign_link_visit_notification(
@@ -390,9 +338,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
f"Ошибка отправки уведомления о рекламной кампании: {e}"
)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
user.subscription
)
has_active_subscription = user.subscription is not None
subscription_is_active = False
if user.subscription:
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
@@ -728,57 +678,31 @@ async def process_rules_accept(
async def process_referral_code_input(
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🎫 REFERRAL/PROMO: Обработка кода: {message.text}")
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
code = message.text.strip()
referral_code = message.text.strip()
# Сначала проверяем, является ли это реферальным кодом
referrer = await get_user_by_referral_code(db, code)
referrer = await get_user_by_referral_code(db, referral_code)
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
logger.info(f"✅ Реферальный код применен: {code}")
await complete_registration(message, state, db)
logger.info(f"✅ Реферальный код применен")
else:
await message.answer(texts.t("REFERRAL_CODE_INVALID", "❌ Неверный реферальный код"))
logger.info(f"❌ Неверный реферальный код")
return
# Если реферальный код не найден, проверяем промокод
from app.database.crud.promocode import check_promocode_validity
promocode_check = await check_promocode_validity(db, code)
if promocode_check["valid"]:
# Промокод валиден - сохраняем его в state для активации после создания пользователя
data['promocode'] = code
await state.set_data(data)
await message.answer(
texts.t(
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
"✅ Промокод принят! Он будет активирован после завершения регистрации."
)
)
logger.info(f"✅ Промокод сохранен для активации: {code}")
await complete_registration(message, state, db)
return
# Ни реферальный код, ни промокод не найдены
await message.answer(
texts.t(
"REFERRAL_OR_PROMO_CODE_INVALID",
"❌ Неверный реферальный код или промокод"
)
)
logger.info(f"❌ Неверный код (ни реферальный, ни промокод): {code}")
return
await complete_registration(message, state, db)
async def process_referral_code_skip(
@@ -837,9 +761,11 @@ async def complete_registration_from_callback(
await db.refresh(existing_user, ['subscription'])
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
existing_user.subscription
)
has_active_subscription = existing_user.subscription is not None
subscription_is_active = False
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
menu_text = await get_main_menu_text(existing_user, texts, db)
@@ -1017,9 +943,11 @@ async def complete_registration_from_callback(
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
getattr(user, "subscription", None)
)
has_active_subscription = bool(getattr(user, "subscription", None))
subscription_is_active = False
if getattr(user, "subscription", None):
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
@@ -1091,9 +1019,11 @@ async def complete_registration(
await db.refresh(existing_user, ['subscription'])
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
existing_user.subscription
)
has_active_subscription = existing_user.subscription is not None
subscription_is_active = False
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
menu_text = await get_main_menu_text(existing_user, texts, db)
@@ -1219,29 +1149,6 @@ async def complete_registration(
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
# Активируем промокод если был сохранен в state
promocode_to_activate = data.get('promocode')
if promocode_to_activate:
try:
from app.handlers.promocode import activate_promocode_for_registration
promocode_result = await activate_promocode_for_registration(
db, user.id, promocode_to_activate, message.bot
)
if promocode_result["success"]:
await message.answer(
texts.t(
"PROMOCODE_ACTIVATED_AT_REGISTRATION",
"✅ Промокод активирован!\n\n{description}"
).format(description=promocode_result["description"])
)
logger.info(f"✅ Промокод {promocode_to_activate} активирован для пользователя {user.id}")
else:
logger.warning(f"⚠️ Не удалось активировать промокод {promocode_to_activate}: {promocode_result.get('error')}")
except Exception as e:
logger.error(f"❌ Ошибка при активации промокода {promocode_to_activate}: {e}")
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
try:
@@ -1294,9 +1201,11 @@ async def complete_registration(
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
getattr(user, "subscription", None)
)
has_active_subscription = bool(getattr(user, "subscription", None))
subscription_is_active = False
if getattr(user, "subscription", None):
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
@@ -1349,43 +1258,30 @@ def _get_subscription_status(user, texts):
return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки")
subscription = user.subscription
actual_status = getattr(subscription, "actual_status", None)
from datetime import datetime
end_date = getattr(subscription, "end_date", None)
end_date_display = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
current_time = datetime.utcnow()
if actual_status == "disabled":
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
if actual_status == "pending":
return texts.t("SUB_STATUS_PENDING", "⏳ Ожидает активации")
if actual_status == "expired" or (end_date and end_date <= current_time):
if end_date_display:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date_display)
return texts.t("SUBSCRIPTION_STATUS_EXPIRED", "🔴 Истекла")
if end_date and end_date <= current_time:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date.strftime('%d.%m.%Y'))
if not end_date:
return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна")
days_left = (end_date - current_time).days
is_trial = actual_status == "trial" or getattr(subscription, "is_trial", False)
if actual_status not in {"active", "trial", None} and not is_trial:
return texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "❓ Статус неизвестен")
is_trial = getattr(subscription, "is_trial", False)
if is_trial:
if days_left > 1 and end_date_display:
if days_left > 1:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(end_date=end_date_display, days=days_left)
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
if days_left == 1:
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
@@ -1396,11 +1292,11 @@ def _get_subscription_status(user, texts):
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
if days_left > 7 and end_date_display:
if days_left > 7:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(end_date=end_date_display, days=days_left)
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
if days_left > 1:
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
@@ -1625,9 +1521,8 @@ async def required_sub_channel_check(
logger.warning(f"Не удалось удалить сообщение: {e}")
if user and user.status != UserStatus.DELETED.value:
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
user.subscription
)
has_active_subscription = bool(user.subscription)
subscription_is_active = bool(user.subscription and user.subscription.is_active)
menu_text = await get_main_menu_text(user, texts, db)
+30 -45
View File
@@ -187,7 +187,7 @@ async def handle_subscription_config_back(
if current_state == SubscriptionStates.selecting_traffic.state:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
@@ -202,26 +202,45 @@ async def handle_subscription_config_back(
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_devices.state:
await _show_previous_configuration_step(callback, state, db_user, texts, db)
elif current_state == SubscriptionStates.confirming_purchase.state:
if settings.is_devices_selection_enabled():
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
data = await state.get_data()
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
selected_countries = data.get('countries', [])
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await state.set_state(SubscriptionStates.selecting_countries)
elif settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await _show_previous_configuration_step(callback, state, db_user, texts, db)
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.confirming_purchase.state:
data = await state.get_data()
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
else:
from app.handlers.menu import show_main_menu
@@ -248,37 +267,3 @@ async def handle_subscription_cancel(
await show_main_menu(callback, db_user, db)
await callback.answer("❌ Покупка отменена")
async def _show_previous_configuration_step(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
texts,
db: AsyncSession,
):
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
data = await state.get_data()
selected_countries = data.get('countries', [])
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
return
if settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
return
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
+1 -1
View File
@@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user(
if user is None:
return 0
promo_group = user.get_primary_promo_group()
promo_group = getattr(user, "promo_group", None)
if promo_group is None:
return 0
-6
View File
@@ -79,7 +79,6 @@ from app.utils.promo_offer import (
)
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
from .summary import present_subscription_summary
async def handle_add_countries(
callback: types.CallbackQuery,
@@ -589,11 +588,6 @@ async def countries_continue(
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
return
if not settings.is_devices_selection_enabled():
if await present_subscription_summary(callback, state, db_user, texts):
await callback.answer()
return
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
-28
View File
@@ -183,13 +183,6 @@ async def handle_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
@@ -240,13 +233,6 @@ async def confirm_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
current_devices = subscription.device_limit
if new_devices_count == current_devices:
@@ -393,13 +379,6 @@ async def execute_change_devices(
subscription = db_user.subscription
current_devices = subscription.device_limit
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
try:
if price > 0:
success = await subtract_user_balance(
@@ -884,13 +863,6 @@ async def confirm_add_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
resume_callback = None
new_total_devices = subscription.device_limit + devices_count
+22 -75
View File
@@ -77,7 +77,6 @@ from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from app.utils.timezone import format_local_datetime
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
@@ -159,18 +158,7 @@ async def _prepare_subscription_summary(
total_servers_discount += total_discount_for_server
selected_server_prices.append(total_price_for_server)
devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: Optional[int] = None
if devices_selection_enabled:
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
else:
forced_disabled_limit = settings.get_disabled_mode_device_limit()
if forced_disabled_limit is None:
devices_selected = settings.DEFAULT_DEVICE_LIMIT
else:
devices_selected = forced_disabled_limit
summary_data['devices'] = devices_selected
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(
@@ -253,20 +241,17 @@ async def _prepare_subscription_summary(
else:
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
details_lines = []
# Добавляем строку базового периода только если цена не равна 0
if base_discount_total > 0 and base_price > 0:
if base_discount_total > 0:
base_line = (
f"- Базовый период: <s>{texts.format_price(base_price_original)}</s> "
f"{texts.format_price(base_price)}"
f" (скидка {period_discount_percent}%:"
f" -{texts.format_price(base_discount_total)})"
)
details_lines.append(base_line)
elif base_price_original > 0:
else:
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
details_lines.append(base_line)
details_lines = [base_line]
if total_traffic_price > 0:
traffic_line = (
@@ -290,7 +275,7 @@ async def _prepare_subscription_summary(
f" -{texts.format_price(total_servers_discount)})"
)
details_lines.append(servers_line)
if devices_selection_enabled and total_devices_price > 0:
if total_devices_price > 0:
devices_line = (
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
f" = {texts.format_price(total_devices_price)}"
@@ -315,28 +300,17 @@ async def _prepare_subscription_summary(
details_text = "\n".join(details_lines)
summary_lines = [
"📋 <b>Сводка заказа</b>",
"",
f"📅 <b>Период:</b> {period_display}",
f"📊 <b>Трафик:</b> {traffic_display}",
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}",
]
if devices_selection_enabled:
summary_lines.append(f"📱 <b>Устройства:</b> {devices_selected}")
summary_lines.extend([
"",
"💰 <b>Детализация стоимости:</b>",
details_text,
"",
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}",
"",
"Подтверждаете покупку?",
])
summary_text = "\n".join(summary_lines)
summary_text = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
return summary_text, summary_data
@@ -353,7 +327,7 @@ async def _build_subscription_period_prompt(
if promo_offer_hint:
lines.extend(["", promo_offer_hint])
promo_text = await _build_promo_group_discount_text(
promo_text = _build_promo_group_discount_text(
db_user,
settings.get_available_subscription_periods(),
texts=texts,
@@ -408,18 +382,7 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
)
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
@@ -447,12 +410,7 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
return 0
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
devices_selection_enabled = settings.is_devices_selection_enabled()
if devices_selection_enabled:
devices_used = await get_current_devices_count(db_user)
else:
devices_used = 0
devices_used = await get_current_devices_count(db_user)
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
@@ -481,21 +439,10 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
subscription_cost = await get_subscription_cost(subscription, db)
info_template = texts.SUBSCRIPTION_INFO
if not devices_selection_enabled:
info_template = info_template.replace(
"\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}",
"",
).replace(
"\n📱 <b>Devices:</b> {devices_used} / {devices_limit}",
"",
)
info_text = info_template.format(
info_text = texts.SUBSCRIPTION_INFO.format(
status=status_text,
type=type_text,
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=traffic_text,
+2 -2
View File
@@ -88,12 +88,12 @@ async def _get_promo_offer_hint(
) -> Optional[str]:
return await build_promo_offer_hint(db, db_user, texts, percent)
async def _build_promo_group_discount_text(
def _build_promo_group_discount_text(
db_user: User,
periods: Optional[List[int]] = None,
texts=None,
) -> str:
promo_group = db_user.get_primary_promo_group()
promo_group = getattr(db_user, "promo_group", None)
if not promo_group:
return ""
File diff suppressed because it is too large Load Diff
-59
View File
@@ -1,59 +0,0 @@
import logging
from typing import Optional, TYPE_CHECKING
from aiogram import types
from aiogram.fsm.context import FSMContext
from app.localization.texts import get_texts
from app.services.subscription_checkout_service import save_subscription_checkout_draft
from app.states import SubscriptionStates
from app.keyboards.inline import get_subscription_confirm_keyboard
if TYPE_CHECKING: # pragma: no cover - only for type checking
from .pricing import _prepare_subscription_summary
logger = logging.getLogger(__name__)
async def present_subscription_summary(
callback: types.CallbackQuery,
state: FSMContext,
db_user,
texts: Optional = None,
) -> bool:
"""Render the subscription purchase summary and switch to the confirmation state.
Returns ``True`` when the summary is shown successfully and ``False`` if
calculation failed (an error is shown to the user in this case).
"""
if texts is None:
texts = get_texts(db_user.language)
data = await state.get_data()
from .pricing import _prepare_subscription_summary
try:
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
except ValueError as exc:
logger.error(
"Ошибка в расчете цены подписки для пользователя %s: %s",
db_user.telegram_id,
exc,
)
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return False
await state.set_data(prepared_data)
await save_subscription_checkout_draft(db_user.id, prepared_data)
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.confirming_purchase)
return True
+6 -13
View File
@@ -80,7 +80,6 @@ from app.utils.promo_offer import (
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
from .countries import _get_available_countries, _should_show_countries_management
from .summary import present_subscription_summary
async def handle_add_traffic(
callback: types.CallbackQuery,
@@ -353,15 +352,12 @@ async def select_traffic(
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
await callback.answer()
return
else:
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
if settings.is_devices_selection_enabled():
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
@@ -369,11 +365,8 @@ async def select_traffic(
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
return
if await present_subscription_summary(callback, state, db_user, texts):
await callback.answer()
await callback.answer()
async def add_traffic(
callback: types.CallbackQuery,
+1 -5
View File
@@ -114,11 +114,7 @@ async def handle_successful_payment(message: types.Message):
)
await message.answer(
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса."
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
)
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
+3 -21
View File
@@ -760,12 +760,6 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
callback_data=f"admin_user_promo_group_{user_id}"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"),
callback_data=f"admin_user_referrals_{user_id}"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
@@ -827,28 +821,20 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
def get_user_promo_group_keyboard(
promo_groups: List[Tuple[Any, int]],
user_id: int,
current_group_ids, # Can be Optional[int] or List[int]
current_group_id: Optional[int],
language: str = "ru"
) -> InlineKeyboardMarkup:
texts = get_texts(language)
# Ensure current_group_ids is a list
if current_group_ids is None:
current_group_ids = []
elif isinstance(current_group_ids, int):
current_group_ids = [current_group_ids]
keyboard: List[List[InlineKeyboardButton]] = []
for group, members_count in promo_groups:
# Check if user has this group
has_group = group.id in current_group_ids
prefix = "" if has_group else "👥"
prefix = "" if current_group_id is not None and group.id == current_group_id else "👥"
count_text = f" ({members_count})" if members_count else ""
keyboard.append([
InlineKeyboardButton(
text=f"{prefix} {group.name}{count_text}",
callback_data=f"admin_user_promo_group_toggle_{user_id}_{group.id}"
callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}"
)
])
@@ -895,10 +881,6 @@ def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(
text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"),
callback_data="promo_type_trial"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_PROMOCODE_TYPE_PROMO_GROUP", "🏷️ Промогруппа"),
callback_data="promo_type_group"
)
],
[
+65 -114
View File
@@ -9,11 +9,7 @@ from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.pricing_utils import (
format_period_description,
apply_percentage_discount,
)
from app.utils.price_display import PriceInfo, format_price_button
from app.utils.pricing_utils import format_period_description, apply_percentage_discount
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
@@ -357,13 +353,10 @@ def get_main_menu_keyboard(
paired_buttons.append(simple_purchase_button)
if show_resume_checkout or has_saved_cart:
resume_callback = (
"return_to_saved_cart" if has_saved_cart else "subscription_resume_checkout"
)
paired_buttons.append(
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data=resume_callback,
callback_data="subscription_resume_checkout",
)
)
@@ -675,7 +668,7 @@ def get_insufficient_balance_keyboard(
return_row = [
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
callback_data="subscription_resume_checkout",
)
]
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
@@ -807,7 +800,7 @@ def get_payment_methods_keyboard_with_cart(
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart"
callback_data="subscription_resume_checkout"
)
])
@@ -864,59 +857,36 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_subscription_period_keyboard(
language: str = DEFAULT_LANGUAGE,
user: Optional[User] = None
) -> InlineKeyboardMarkup:
"""
Generate subscription period selection keyboard with personalized pricing.
Args:
language: User's language code
user: User object for personalized discounts (None = default discounts)
Returns:
InlineKeyboardMarkup with period buttons showing personalized prices
"""
from app.utils.price_display import calculate_user_price
def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
available_periods = settings.get_available_subscription_periods()
period_texts = {
14: texts.PERIOD_14_DAYS,
30: texts.PERIOD_30_DAYS,
60: texts.PERIOD_60_DAYS,
90: texts.PERIOD_90_DAYS,
180: texts.PERIOD_180_DAYS,
360: texts.PERIOD_360_DAYS
}
for days in available_periods:
# Get base price for this period
base_price = PERIOD_PRICES.get(days, 0)
# Calculate personalized price with user's discounts
price_info = calculate_user_price(user, base_price, days, "period")
# Format period description
period_display = format_period_description(days, language)
# Format button text with discount display
button_text = format_price_button(
period_label=period_display,
price_info=price_info,
format_price_func=texts.format_price,
emphasize=False,
add_exclamation=False
)
keyboard.append([
InlineKeyboardButton(
text=button_text,
callback_data=f"period_{days}"
)
])
if days in period_texts:
keyboard.append([
InlineKeyboardButton(
text=period_texts[days],
callback_data=f"period_{days}"
)
])
# Кнопка "Простая покупка" была убрана из выбора периода подписки
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -1159,14 +1129,6 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_platega_enabled() and settings.get_platega_active_methods():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
callback_data=_build_callback("platega"),
)
])
if settings.is_cryptobot_enabled():
keyboard.append([
InlineKeyboardButton(
@@ -1440,8 +1402,27 @@ def _get_days_word(days: int) -> str:
# Deprecated: get_extend_subscription_keyboard() was removed.
# Use get_extend_subscription_keyboard_with_prices() instead for personalized pricing.
def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
periods = [
(14, f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"),
(30, f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"),
(60, f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"),
(90, f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}")
]
for days, text in periods:
keyboard.append([
InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_add_traffic_keyboard(
@@ -1978,39 +1959,15 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
if isinstance(price_info, dict):
final_price = price_info.get("final")
original_price = price_info.get("original", 0)
if final_price is None:
final_price = price_info.get("original", 0)
else:
final_price = price_info
original_price = price_info
period_display = format_period_description(days, language)
# Create PriceInfo from already calculated prices
# Note: original_price and final_price are calculated in the handler
discount_percent = 0
if original_price > final_price and original_price > 0:
discount_percent = ((original_price - final_price) * 100) // original_price
price_info_obj = PriceInfo(
base_price=original_price,
final_price=final_price,
discount_percent=discount_percent
)
# Format button using unified system
button_text = format_price_button(
period_label=period_display,
price_info=price_info_obj,
format_price_func=texts.format_price,
emphasize=False,
add_exclamation=False
)
keyboard.append([
InlineKeyboardButton(
text=button_text,
text=f"📅 {period_display} - {texts.format_price(final_price)}",
callback_data=f"extend_period_{days}"
)
])
@@ -2125,39 +2082,33 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
texts = get_texts(language)
keyboard = []
if show_countries_management:
keyboard.append([
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
])
if settings.is_traffic_selectable():
keyboard.append([
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
])
keyboard.append([
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
if settings.is_devices_selection_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
callback_data="subscription_change_devices"
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
callback_data="subscription_manage_devices"
)
keyboard.extend([
[
InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices")
],
[
InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices")
]
])
if settings.is_traffic_selectable():
keyboard.insert(-2, [
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
keyboard.insert(-2, [
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+4 -50
View File
@@ -314,7 +314,6 @@
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance",
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Promo Group",
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
@@ -371,18 +370,7 @@
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group \"{name}\" updated.",
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Priority: {priority}",
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Enter group priority (0 = base, higher = higher priority):",
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Enter new priority (current: {current}):",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Priority",
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Priority must be a non-negative integer",
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Primary: {name} (Priority: {priority})",
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Additional groups:",
"ADMIN_USER_PROMO_GROUPS_NONE": "User has no promo groups",
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Group «{name}» added",
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Group «{name}» removed",
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Cannot remove the last promo group",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group {name} updated.",
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Promo offers</b>\n\nSelect a template to configure:",
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "After activation the discount lasts for {hours} h.",
"ADMIN_PROMO_OFFER_ALLOWED": "Available segments:",
@@ -868,9 +856,6 @@
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
"DEVICE_CHANGE_NO_REFUND_INFO": "️ Payments are not refunded",
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
"DEVICES_INVALID_REQUEST": "❌ Invalid request",
"DEVICES_INVALID_COUNT": "❌ Invalid device count",
"DEVICES_SELECTION_DISABLED": "⚠️ Device selection is unavailable",
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
@@ -1027,7 +1012,6 @@
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
@@ -1061,8 +1045,6 @@
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
@@ -1081,18 +1063,6 @@
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter an amount from {min_amount} to {max_amount}.\nPayment is processed by Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Select a Platega payment method before entering the amount",
"PLATEGA_AMOUNT_TOO_LOW": "Minimum amount for Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Maximum amount for Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Failed to create Platega payment. Please try again later or contact support.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Payment via Platega ({method})</b>\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 <b>Instructions:</b>\n1. Tap the Pay button\n2. Follow the payment provider instructions\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ If you have issues, contact {support}",
"PLATEGA_PAY_BUTTON": "💳 Pay via {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Payment already credited",
"PLATEGA_PAYMENT_STATUS": "Current payment status: {status}",
"PAY_NOW_BUTTON": "💳 Pay",
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
@@ -1148,11 +1118,7 @@
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a referral code or promo code?</b>\n\nIf you have a promo code or referral link from a friend, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Invalid referral code or promo code",
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Invalid referral code or promo code.\n\n💡 If you have a referral code or promo code, please double-check the spelling.\n⏭️ To continue registration without a code, use the /start command.",
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Promo code activated!\n\n{description}",
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Promo code accepted! It will be activated after registration is complete.",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_CODE_SKIP": "⏭️ Skip",
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
@@ -1244,7 +1210,7 @@
"SKIP_BUTTON": "Skip ➡️",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\n⚠️ <b>Important:</b> Balance top-up does not automatically activate your subscription. Please activate your subscription separately!\n\n🔄 If you have a saved subscription cart and auto-purchase is enabled, your subscription will be automatically purchased after balance top-up.\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
@@ -1339,8 +1305,6 @@
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
"SUB_STATUS_DISABLED": "⚫ Disabled",
"SUB_STATUS_PENDING": "⏳ Pending activation",
"SUB_STATUS_NONE": "❌ Not available",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
@@ -1411,19 +1375,10 @@
"TRAFFIC_NO_CHANGE": "️ Traffic limit was not changed",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_UNLIMITED_SHORT": "Unlimited",
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a trial plan{price_line}:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Activation price:</b> {price}",
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Not enough funds to activate the trial.\nRequired: {required}\nOn balance: {balance}\nMissing: {missing}\n\nTop up your balance and try again.",
"TRIAL_PAYMENT_FAILED": "We couldn't charge your balance to activate the trial. Please try again later.",
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
@@ -1475,7 +1430,6 @@
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Create poll</b>\n\nEnter poll title:",
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.",
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.",
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Description omitted.",
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}",
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.",
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.",
+3 -69
View File
@@ -314,7 +314,6 @@
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс",
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Промогруппа",
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
@@ -372,17 +371,6 @@
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Приоритет: {priority}",
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Введите новый приоритет (текущий: {current}):",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Приоритет",
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Приоритет должен быть неотрицательным целым числом",
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Основная: {name} (Priority: {priority})",
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Дополнительные группы:",
"ADMIN_USER_PROMO_GROUPS_NONE": "У пользователя нет промогрупп",
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Группа «{name}» добавлена",
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Группа «{name}» удалена",
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Нельзя удалить последнюю промогруппу",
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.",
"ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:",
@@ -725,26 +713,6 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
"ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.",
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать",
"ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ <b>Редактирование рефералов</b>\n\nОтправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.",
"ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.",
"ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.",
"ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}",
"ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}",
"ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}",
"ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.",
"ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.",
"ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}",
"ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}",
"ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
@@ -888,9 +856,6 @@
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
"DEVICES_INVALID_REQUEST": "❌ Некорректный запрос",
"DEVICES_INVALID_COUNT": "❌ Некорректное количество устройств",
"DEVICES_SELECTION_DISABLED": "⚠️ Выбор количества устройств недоступен",
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
@@ -1047,7 +1012,6 @@
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
@@ -1081,8 +1045,6 @@
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
@@ -1101,18 +1063,6 @@
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от {min_amount} до {max_amount}.\nОплата происходит через Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Выберите способ оплаты Platega перед вводом суммы",
"PLATEGA_AMOUNT_TOO_LOW": "Минимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Максимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Platega ({method})</b>\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку «Оплатить»\n2. Следуйте подсказкам платёжной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PLATEGA_PAY_BUTTON": "💳 Оплатить через {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Платёж уже зачислен",
"PLATEGA_PAYMENT_STATUS": "Текущий статус платежа: {status}",
"PAY_NOW_BUTTON": "💳 Оплатить",
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
@@ -1168,11 +1118,7 @@
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код или промокод?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Неверный реферальный код или промокод",
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Неверный реферальный код или промокод.\n\n💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без кода используйте команду /start",
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Промокод активирован!\n\n{description}",
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Промокод принят! Он будет активирован после завершения регистрации.",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
@@ -1264,7 +1210,7 @@
"SKIP_BUTTON": "⏭️ Пропустить",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n\n🆔 ID транзакции: {transaction_id}...\n\n⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. Обязательно активируйте подписку отдельно!\n\n🔄 При наличии сохранённой корзины подписки и включенной автопокупке, подписка будет приобретена автоматически после пополнения баланса.\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
@@ -1359,8 +1305,6 @@
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
"SUB_STATUS_DISABLED": "⚫ Отключена",
"SUB_STATUS_PENDING": "⏳ Ожидает активации",
"SUB_STATUS_NONE": "❌ Отсутствует",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
@@ -1431,19 +1375,10 @@
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_UNLIMITED_SHORT": "Безлимит",
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить тестовую подписку{price_line}:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Стоимость активации:</b> {price}",
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств для активации триала.\nНеобходимо: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nПополните баланс и попробуйте снова.",
"TRIAL_PAYMENT_FAILED": "Не удалось списать средства для активации триала. Попробуйте позже.",
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
@@ -1495,7 +1430,6 @@
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Описание пропущено.",
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
+12
View File
@@ -30,6 +30,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
if language_code == "ru":
return {
"PERIOD_14_DAYS": f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
@@ -49,6 +55,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
if language_code == "en":
return {
"PERIOD_14_DAYS": f"📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": f"📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": f"📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": f"📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": f"📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": f"📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
+10 -74
View File
@@ -1,6 +1,6 @@
import logging
from typing import Callable, Dict, Any, Awaitable, Optional
from aiogram import BaseMiddleware, Bot, types
from aiogram import BaseMiddleware, Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
@@ -8,7 +8,6 @@ from aiogram.enums import ChatMemberStatus
from app.config import settings
from app.database.database import get_db
from app.database.crud.campaign import get_campaign_by_start_parameter
from app.database.crud.subscription import deactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import SubscriptionStatus
@@ -17,7 +16,6 @@ from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.check_reg_process import is_registration_process
from app.services.subscription_service import SubscriptionService
from app.services.admin_notification_service import AdminNotificationService
logger = logging.getLogger(__name__)
@@ -106,7 +104,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if telegram_id:
await self._deactivate_trial_subscription(telegram_id)
await self._capture_start_payload(state, event, bot)
await self._capture_start_payload(state, event)
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
@@ -115,12 +113,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
return await self._deny_message(event, bot, channel_link)
else:
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
await self._capture_start_payload(state, event, bot)
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
except TelegramForbiddenError as e:
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
await self._capture_start_payload(state, event, bot)
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
except TelegramBadRequest as e:
if "chat not found" in str(e).lower():
@@ -129,18 +127,13 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
else:
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
await self._capture_start_payload(state, event, bot)
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
return await handler(event, data)
async def _capture_start_payload(
self,
state: Optional[FSMContext],
event: TelegramObject,
bot: Optional[Bot] = None,
) -> None:
async def _capture_start_payload(self, state: Optional[FSMContext], event: TelegramObject) -> None:
if not state:
return
@@ -166,69 +159,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
payload = parts[1]
data = await state.get_data() or {}
if data.get("pending_start_payload") != payload:
data["pending_start_payload"] = payload
await state.set_data(data)
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
if bot and message.from_user:
await self._try_send_campaign_visit_notification(
bot,
message.from_user,
state,
payload,
)
async def _try_send_campaign_visit_notification(
self,
bot: Bot,
telegram_user: types.User,
state: FSMContext,
payload: str,
) -> None:
try:
data = await state.get_data() or {}
except Exception as error:
logger.error(
"❌ Не удалось получить данные состояния для уведомления по кампании %s: %s",
payload,
error,
)
if data.get("pending_start_payload") == payload:
return
if data.get("campaign_notification_sent"):
return
async for db in get_db():
try:
campaign = await get_campaign_by_start_parameter(
db,
payload,
only_active=True,
)
if not campaign:
break
user = await get_user_by_telegram_id(db, telegram_user.id)
notification_service = AdminNotificationService(bot)
sent = await notification_service.send_campaign_link_visit_notification(
db,
telegram_user,
campaign,
user,
)
if sent:
await state.update_data(campaign_notification_sent=True)
break
except Exception as error:
logger.error(
"❌ Ошибка отправки уведомления о переходе по кампании %s: %s",
payload,
error,
)
finally:
break
data["pending_start_payload"] = payload
await state.set_data(data)
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
async for db in get_db():
+44 -91
View File
@@ -19,7 +19,6 @@ from app.database.models import (
TransactionType,
User,
)
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -78,20 +77,6 @@ class AdminNotificationService:
)
return None
def _get_user_display(self, user: User) -> str:
first_name = getattr(user, "first_name", "") or ""
if first_name:
return first_name
username = getattr(user, "username", "") or ""
if username:
return username
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return "IDUnknown"
return f"ID{telegram_id}"
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
discount_lines: List[str] = []
@@ -190,9 +175,7 @@ class AdminNotificationService:
self,
db: AsyncSession,
user: User,
subscription: Subscription,
*,
charged_amount_kopeks: Optional[int] = None,
subscription: Subscription
) -> bool:
if not self._is_enabled():
return False
@@ -202,42 +185,26 @@ class AdminNotificationService:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
trial_device_limit = subscription.device_limit
if trial_device_limit is None:
fallback_forced_limit = settings.get_disabled_mode_device_limit()
if fallback_forced_limit is not None:
trial_device_limit = fallback_forced_limit
else:
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
payment_block = ""
if charged_amount_kopeks and charged_amount_kopeks > 0:
payment_block = (
f"\n💳 <b>Оплата за активацию:</b> {settings.format_price(charged_amount_kopeks)}"
)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
👤 <b>Пользователь:</b> {user_display}
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
<b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
📱 Устройства: {trial_device_limit}
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
{payment_block}
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
🔗 <b>Реферер:</b> {referrer_info}
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -260,29 +227,28 @@ class AdminNotificationService:
try:
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
if was_trial_conversion:
user_status = "🎯 Конверсия из триала"
elif user.has_had_paid_subscription:
user_status = "🔄 Продление/Обновление"
else:
user_status = "🆕 Первая покупка"
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
transaction_id = transaction.id if transaction else ""
message = f"""💎 <b>{event_type}</b>
👤 <b>Пользователь:</b> {user_display}
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
@@ -298,11 +264,11 @@ class AdminNotificationService:
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
🔗 <b>Реферер:</b> {referrer_info}
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -349,7 +315,7 @@ class AdminNotificationService:
Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
<i>Автоматическая проверка обновлений {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
<i>Автоматическая проверка обновлений {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -374,7 +340,7 @@ class AdminNotificationService:
🔄 Следующая попытка через час.
Проверьте доступность GitHub API и настройки сети.
<i>Система автоматических обновлений {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
<i>Система автоматических обновлений {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -397,14 +363,13 @@ class AdminNotificationService:
balance_change = user.balance_kopeks - old_balance
subscription_status = self._get_subscription_status(subscription)
promo_block = self._format_promo_group_block(promo_group)
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
user_display = self._get_user_display(user)
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
👤 <b>Пользователь:</b> {user_display}
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
📱 <b>Username:</b> @{user.username or 'отсутствует'}
💳 <b>Статус:</b> {topup_status}
{promo_block}
@@ -575,16 +540,15 @@ class AdminNotificationService:
servers_info = await self._get_servers_info(subscription.connected_squads)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
👤 <b>Пользователь:</b> {user_display}
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
📱 <b>Username:</b> @{user.username or 'отсутствует'}
{promo_block}
@@ -595,8 +559,8 @@ class AdminNotificationService:
📅 <b>Продление:</b>
Добавлено дней: {extended_days}
📆 Было до: {format_local_datetime(old_end_date, '%d.%m.%Y %H:%M')}
📆 Стало до: {format_local_datetime(current_end_date, '%d.%m.%Y %H:%M')}
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
📱 <b>Текущие параметры:</b>
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
@@ -605,7 +569,7 @@ class AdminNotificationService:
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -628,14 +592,13 @@ class AdminNotificationService:
promo_block = self._format_promo_group_block(promo_group)
type_display = self._get_promocode_type_display(promocode_data.get("type"))
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
user_display = self._get_user_display(user)
message_lines = [
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
"",
f"👤 <b>Пользователь:</b> {user_display}",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
promo_block,
"",
@@ -658,7 +621,7 @@ class AdminNotificationService:
valid_until = promocode_data.get("valid_until")
if valid_until:
message_lines.append(
f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}"
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
if isinstance(valid_until, datetime)
else f"⏳ Действует до: {valid_until}"
)
@@ -669,7 +632,7 @@ class AdminNotificationService:
"📝 <b>Эффект:</b>",
effect_description.strip() or "✅ Промокод активирован",
"",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
@@ -723,7 +686,7 @@ class AdminNotificationService:
message_lines.extend(
[
"",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
@@ -756,14 +719,13 @@ class AdminNotificationService:
)
elif automatic:
initiator_line = "🤖 Автоматическое назначение"
user_display = self._get_user_display(user)
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user_display}",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
]
@@ -788,7 +750,7 @@ class AdminNotificationService:
[
"",
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
@@ -866,9 +828,9 @@ class AdminNotificationService:
return "❌ Нет подписки"
if subscription.is_trial:
return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})"
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
elif subscription.is_active:
return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})"
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
else:
return "❌ Неактивна"
@@ -939,9 +901,7 @@ class AdminNotificationService:
if isinstance(enabled_at, str):
from datetime import datetime
enabled_at = datetime.fromisoformat(enabled_at)
message_parts.append(
f"🕐 <b>Время включения:</b> {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}"
)
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
message_parts.append("")
@@ -953,9 +913,7 @@ class AdminNotificationService:
if isinstance(disabled_at, str):
from datetime import datetime
disabled_at = datetime.fromisoformat(disabled_at)
message_parts.append(
f"🕐 <b>Время отключения:</b> {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}"
)
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
if details.get("duration"):
duration = details["duration"]
@@ -1014,10 +972,9 @@ class AdminNotificationService:
else:
message_parts.append("Автоматический мониторинг API остановлен.")
from datetime import datetime
message_parts.append("")
message_parts.append(
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
)
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
@@ -1063,9 +1020,7 @@ class AdminNotificationService:
if isinstance(last_check, str):
from datetime import datetime
last_check = datetime.fromisoformat(last_check)
message_parts.append(
f"🕐 <b>Последняя проверка:</b> {format_local_datetime(last_check, '%H:%M:%S')}"
)
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
if status == "online":
if details.get("uptime"):
@@ -1111,10 +1066,9 @@ class AdminNotificationService:
message_parts.append("")
message_parts.append("Панель временно недоступна для обслуживания.")
from datetime import datetime
message_parts.append("")
message_parts.append(
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
)
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
@@ -1141,7 +1095,6 @@ class AdminNotificationService:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
update_types = {
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
@@ -1154,9 +1107,9 @@ class AdminNotificationService:
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user_display}",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
promo_block,
"",
@@ -1189,11 +1142,11 @@ class AdminNotificationService:
message_lines.extend(
[
"",
f"📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}",
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
f"🔗 <b>Рефер:</b> {referrer_info}",
"",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
File diff suppressed because it is too large Load Diff
+18 -38
View File
@@ -137,50 +137,30 @@ class BroadcastService:
keyboard = self._build_keyboard(config.selected_buttons)
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
async def send_single_message(user):
"""Отправляет одно сообщение с семафором ограничения"""
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 100
for i in range(0, len(recipients), batch_size):
for index, user in enumerate(recipients, start=1):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
failed_count += 1
continue
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
try:
await self._deliver_message(telegram_id, config, keyboard)
sent_count += 1
except Exception as exc: # noqa: BLE001
failed_count += 1
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
if index % 20 == 0:
await asyncio.sleep(1)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
+9 -4
View File
@@ -120,16 +120,19 @@ class AdvertisingCampaignService:
return CampaignBonusResult(success=False)
traffic_limit = campaign.subscription_traffic_gb
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
device_limit = (
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
)
squads = list(campaign.subscription_squads or [])
if not squads:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(db)
trial_uuid = await get_random_trial_squad_uuid(
db,
getattr(settings, "TRIAL_SQUAD_UUID", None),
)
if trial_uuid:
squads = [trial_uuid]
except Exception as error:
@@ -138,6 +141,8 @@ class AdvertisingCampaignService:
campaign.id,
error,
)
if getattr(settings, "TRIAL_SQUAD_UUID", None):
squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
+56 -135
View File
@@ -7,7 +7,6 @@ from dataclasses import dataclass
from app.config import settings
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
from app.utils.cache import cache
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -46,9 +45,6 @@ class MaintenanceService:
def get_maintenance_message(self) -> str:
if self._status.auto_enabled:
last_check_display = format_local_datetime(
self._status.last_check, "%H:%M:%S", "неизвестно"
)
return f"""
🔧 Технические работы!
@@ -56,7 +52,7 @@ class MaintenanceService:
Мы работаем над восстановлением. Попробуйте через несколько минут.
🔄 Последняя проверка: {last_check_display}
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
"""
else:
return settings.get_maintenance_message()
@@ -83,12 +79,7 @@ class MaintenanceService:
}
emoji = emoji_map.get(alert_type, "")
timestamp = format_local_datetime(
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
)
formatted_message = (
f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>"
)
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
return await notification_service._send_message(formatted_message)
@@ -161,14 +152,11 @@ class MaintenanceService:
await self._save_status_to_cache()
enabled_time = format_local_datetime(
self._status.enabled_at, "%d.%m.%Y %H:%M:%S %Z"
)
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
📋 <b>Причина:</b> {self._status.reason}
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
🕐 <b>Время:</b> {enabled_time}
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
Обычные пользователи временно не смогут использовать бота."""
@@ -209,13 +197,10 @@ class MaintenanceService:
else:
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
notification_time = format_local_datetime(
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
)
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
🕐 <b>Время:</b> {notification_time}
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
{duration_str}
Сервис снова доступен для пользователей."""
@@ -238,23 +223,15 @@ class MaintenanceService:
await self._load_status_from_cache()
self._check_task = asyncio.create_task(self._monitoring_loop())
logger.info(
"🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)",
settings.get_maintenance_check_interval(),
settings.get_maintenance_retry_attempts(),
)
await self._notify_admins(
f"""Мониторинг технических работ запущен
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
await self._notify_admins(f"""Мониторинг технических работ запущен
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
🔁 <b>Повторных попыток:</b> {settings.get_maintenance_retry_attempts()}
Система будет следить за доступностью API.""",
"info",
)
Система будет следить за доступностью API.""", "info")
return True
@@ -283,130 +260,75 @@ class MaintenanceService:
try:
if self._is_checking:
return self._status.api_status
self._is_checking = True
self._status.last_check = datetime.utcnow()
auth_params = settings.get_remnawave_auth_params()
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
secret_key = (auth_params.get("secret_key") or "").strip() or None
username = (auth_params.get("username") or "").strip() or None
password = (auth_params.get("password") or "").strip() or None
if not base_url:
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
if not api_key:
logger.error("REMNAWAVE_API_KEY не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password
base_url=auth_params["base_url"],
api_key=auth_params["api_key"],
secret_key=auth_params["secret_key"],
username=auth_params["username"],
password=auth_params["password"]
)
attempts = settings.get_maintenance_retry_attempts()
async with api:
for attempt in range(1, attempts + 1):
is_connected = await test_api_connection(api)
if is_connected:
if attempt > 1:
logger.info(
"API Remnawave ответило с %s попытки", attempt
)
if not self._status.api_status:
recovery_time = format_local_datetime(
self._status.last_check, "%H:%M:%S %Z"
)
await self._notify_admins(
f"""API Remnawave восстановлено!
is_connected = await test_api_connection(api)
if is_connected:
if not self._status.api_status:
await self._notify_admins(f"""API Remnawave восстановлено!
<b>Статус:</b> Доступно
🕐 <b>Время восстановления:</b> {recovery_time}
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
API снова отвечает на запросы.""",
"success",
)
self._status.api_status = True
self._status.consecutive_failures = 0
if self._status.is_active and self._status.auto_enabled:
await self.disable_maintenance()
logger.info("✅ API восстановился, режим техработ автоматически отключен")
return True
if attempt < attempts:
logger.warning(
"API Remnawave недоступно (попытка %s/%s)",
attempt,
attempts,
)
await asyncio.sleep(1)
was_available = self._status.api_status
self._status.api_status = False
self._status.consecutive_failures += 1
if was_available:
detection_time = format_local_datetime(
self._status.last_check, "%H:%M:%S %Z"
)
await self._notify_admins(
f"""API Remnawave недоступно!
API снова отвечает на запросы.""", "success")
self._status.api_status = True
self._status.consecutive_failures = 0
if self._status.is_active and self._status.auto_enabled:
await self.disable_maintenance()
logger.info("✅ API восстановился, режим техработ автоматически отключен")
return True
else:
was_available = self._status.api_status
self._status.api_status = False
self._status.consecutive_failures += 1
if was_available:
await self._notify_admins(f"""API Remnawave недоступно!
<b>Статус:</b> Недоступно
🕐 <b>Время обнаружения:</b> {detection_time}
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
Началась серия неудачных проверок API.""",
"error",
)
if (
self._status.consecutive_failures >= self._max_consecutive_failures
and not self._status.is_active
and settings.is_maintenance_auto_enable()
):
await self.enable_maintenance(
reason=(
f"Автоматическое включение после {self._status.consecutive_failures} "
"неудачных проверок API"
),
auto=True
)
return False
Началась серия неудачных проверок API.""", "error")
if (self._status.consecutive_failures >= self._max_consecutive_failures and
not self._status.is_active and
settings.is_maintenance_auto_enable()):
await self.enable_maintenance(
reason=f"Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API",
auto=True
)
return False
except Exception as e:
logger.error(f"Ошибка проверки API: {e}")
if self._status.api_status:
error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z")
await self._notify_admins(
f"""Ошибка при проверке API Remnawave
await self._notify_admins(f"""Ошибка при проверке API Remnawave
<b>Ошибка:</b> {str(e)}
🕐 <b>Время:</b> {error_time}
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
Не удалось выполнить проверку доступности API.""",
"error",
)
Не удалось выполнить проверку доступности API.""", "error")
self._status.api_status = False
self._status.consecutive_failures += 1
@@ -476,7 +398,6 @@ API снова отвечает на запросы.""",
"api_status": self._status.api_status,
"consecutive_failures": self._status.consecutive_failures,
"monitoring_active": self._check_task is not None and not self._check_task.done(),
"monitoring_configured": settings.is_maintenance_monitoring_enabled(),
"auto_enable_configured": settings.is_maintenance_auto_enable(),
"check_interval": settings.get_maintenance_check_interval(),
"bot_connected": self._bot is not None
+18 -110
View File
@@ -38,19 +38,7 @@ from app.database.crud.user import (
subtract_user_balance,
cleanup_expired_promo_offer_discounts,
)
from app.utils.timezone import format_local_datetime
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
from app.database.models import (
MonitoringLog,
SubscriptionStatus,
Subscription,
User,
Ticket,
TicketStatus,
UserPromoGroup,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
from app.services.payment_service import PaymentService
@@ -253,6 +241,9 @@ class MonitoringService:
await expire_subscription(db, subscription)
user = await get_user_by_id(db, subscription.user_id)
if user and user.remnawave_uuid:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
if user and self.bot:
await self._send_subscription_expired_notification(user)
@@ -291,34 +282,21 @@ class MonitoringService:
is_active = False
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
if not self.subscription_service.is_configured:
logger.warning(
"RemnaWave API не настроен. Пропускаем обновление пользователя %s",
subscription.user_id,
)
return None
async with self.subscription_service.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
update_kwargs = dict(
async with self.api as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=subscription.connected_squads
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
updated_user = await api.update_user(**update_kwargs)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
@@ -394,12 +372,7 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
selectinload(Subscription.user)
.selectinload(User.user_promo_groups)
.selectinload(UserPromoGroup.promo_group),
)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
@@ -581,11 +554,7 @@ class MonitoringService:
)
continue
if (
subscription.status == SubscriptionStatus.ACTIVE.value
and subscription.is_trial
and not is_member
):
if subscription.status == SubscriptionStatus.ACTIVE.value and not is_member:
subscription = await deactivate_subscription(db, subscription)
disabled_count += 1
logger.info(
@@ -619,11 +588,7 @@ class MonitoringService:
subscription.id,
"trial_channel_unsubscribed",
)
elif (
subscription.status == SubscriptionStatus.DISABLED.value
and subscription.is_trial
and is_member
):
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.utcnow()
await db.commit()
@@ -937,10 +902,7 @@ class MonitoringService:
if not user:
continue
# Правильный расчет стоимости продления с учетом всех параметров подписки
renewal_cost = await self.subscription_service.calculate_renewal_price(
subscription, 30, db, user=user
)
renewal_cost = settings.PRICE_30_DAYS
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
charge_amount = renewal_cost
promo_discount_value = 0
@@ -1064,7 +1026,7 @@ class MonitoringService:
message = f"""
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}.
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
💳 <b>Автоплатеж:</b> {autopay_status}
@@ -1108,56 +1070,6 @@ class MonitoringService:
try:
texts = get_texts(user.language)
# Рассчитываем минимальную цену за подписку с минимальной конфигурацией
from app.config import settings, PERIOD_PRICES
from app.utils.pricing_utils import apply_percentage_discount
# Базовая цена за 30 дней
base_price_original = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS)
# Применяем скидку промогруппы для категории "period"
promo_group_discount = user.get_promo_discount("period", 30) if user else 0
# Применяем пользовательскую промо-скидку (если есть)
user_discount_percent = self._get_user_promo_offer_discount_percent(user)
# Общая скидка - максимальная из промогруппы и пользовательской
total_discount_percent = max(promo_group_discount, user_discount_percent)
base_price, _ = apply_percentage_discount(base_price_original, total_discount_percent)
# Добавляем цену за трафик (если фиксированный трафик включён)
if settings.is_traffic_fixed():
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
# Применяем скидки на трафик
traffic_discount = user.get_promo_discount("traffic", 30) if user else 0
traffic_price, _ = apply_percentage_discount(traffic_price, traffic_discount)
else:
traffic_price = 0 # Трафик не фиксирован, цена включена в базовую
# Добавляем цену за серверы (предполагаем минимум 1 сервер по минимальной цене)
# Вместо сложного запроса к БД, используем настройки
# Для минимальной конфигурации - один сервер с минимальной ценой
min_server_price = getattr(settings, 'MIN_SERVER_PRICE', 0) or 0
if min_server_price == 0:
# Если нет явной минимальной цены, используем базовую цену
# В реальных условиях цена сервера будет определяться в ходе оформления подписки
min_server_price = 0
# Добавляем цену за устройства (если больше базового лимита)
# В минимальной конфигурации - базовый лимит, без доп. устройств
device_limit = settings.DEFAULT_DEVICE_LIMIT
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price = additional_devices * settings.PRICE_PER_DEVICE
# Для простоты и правильной работы без обращения к БД, рассчитываем минимальную цену как:
# базовая цена + минимальная цена за трафик (если есть фиксированный)
min_server_price = 0 # для минимальной конфигурации с 1 сервером используем 0 или минимальную известную
# Попробуем получить минимальную цену сервера из настроек или используем подходящее значение
# Находим минимальную возможную цену из возможных цен серверов
# В упрощенном варианте используем базовую конфигурацию: базовая цена + трафик
min_total_price = base_price + traffic_price
message = f"""
🎁 <b>Тестовая подписка скоро закончится!</b>
@@ -1167,7 +1079,7 @@ class MonitoringService:
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(min_total_price)}
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Скорость до 1ГБит/сек
@@ -1230,7 +1142,7 @@ class MonitoringService:
message = template.format(
price=settings.format_price(settings.PRICE_30_DAYS),
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -1346,7 +1258,7 @@ class MonitoringService:
),
)
message = template.format(
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
price=settings.format_price(settings.PRICE_30_DAYS),
)
@@ -1423,7 +1335,7 @@ class MonitoringService:
message = template.format(
percent=percent,
expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"),
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
trigger_days=trigger_days or "",
)
@@ -1561,11 +1473,7 @@ class MonitoringService:
if now.minute != 0:
return
if not self.subscription_service.is_configured:
logger.warning("RemnaWave API не настроен. Пропускаем синхронизацию")
return
async with self.subscription_service.get_api_client() as api:
async with self.subscription_service.api as api:
system_stats = await api.get_system_stats()
await self._log_monitoring_event(
+4 -10
View File
@@ -83,28 +83,22 @@ class Pal24Service:
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
return await self.client.get_payment_status(payment_id)
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
"""Возвращает список платежей, связанных со счетом."""
logger.debug("Запрашиваем платежи Pal24 счёта %s", bill_id)
return await self.client.get_bill_payments(bill_id)
@staticmethod
def parse_callback(payload: Dict[str, Any]) -> Dict[str, Any]:
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
missing = [field for field in required_fields if field not in payload]
if missing:
raise Pal24APIError(f"Pal24 callback missing fields: {', '.join(missing)}")
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
inv_id = str(payload["InvId"])
out_sum = str(payload["OutSum"])
signature = str(payload["SignatureValue"])
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
raise Pal24APIError("Pal24 callback signature mismatch")
raise Pal24APIError("Pal24 postback signature mismatch")
logger.info(
"Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s",
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
inv_id,
payload.get("Status"),
payload.get("TrsId"),
-2
View File
@@ -12,7 +12,6 @@ from .cryptobot import CryptoBotPaymentMixin
from .heleket import HeleketPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .platega import PlategaPaymentMixin
from .wata import WataPaymentMixin
__all__ = [
@@ -24,6 +23,5 @@ __all__ = [
"HeleketPaymentMixin",
"MulenPayPaymentMixin",
"Pal24PaymentMixin",
"PlategaPaymentMixin",
"WataPaymentMixin",
]
+5 -38
View File
@@ -23,7 +23,6 @@ from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.user_cart_service import user_cart_service
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
@@ -42,7 +41,6 @@ class PaymentCommonMixin:
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
)
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
first_button = build_miniapp_or_callback_button(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
@@ -54,45 +52,18 @@ class PaymentCommonMixin:
),
)
# Кнопка активации подписки (всегда отображается)
activate_subscription_button = build_miniapp_or_callback_button(
text="🚀 Активировать подписку",
callback_data="menu_buy" # Используем ту же callback_data что и "Купить подписку"
)
keyboard_rows: list[list[InlineKeyboardButton]] = [
[first_button],
[activate_subscription_button]
]
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
if user:
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
except Exception as cart_error:
logger.warning(
"Не удалось проверить наличие сохраненной корзины у пользователя %s: %s",
user.id,
cart_error,
)
has_saved_cart = False
if has_saved_cart:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists):
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
callback_data="subscription_resume_checkout",
)
])
else:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists):
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
])
# Стандартные кнопки быстрого доступа к балансу и главному меню.
keyboard_rows.append([
@@ -138,11 +109,7 @@ class PaymentCommonMixin:
"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"💳 Способ: {payment_method}\n\n"
"Средства зачислены на ваш баланс!\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса."
"Средства зачислены на ваш баланс!"
)
await self.bot.send_message(
+70 -210
View File
@@ -1,9 +1,8 @@
"""Mixin с логикой обработки платежей CryptoBot."""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
@@ -11,7 +10,6 @@ from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
@@ -22,33 +20,6 @@ from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _AdminNotificationContext:
user_id: int
transaction_id: int
old_balance: int
topup_status: str
referrer_info: str
@dataclass(slots=True)
class _UserNotificationPayload:
telegram_id: int
text: str
parse_mode: Optional[str]
reply_markup: Any
amount_rubles: float
asset: str
@dataclass(slots=True)
class _SavedCartNotificationPayload:
telegram_id: int
text: str
reply_markup: Any
user_id: int
class CryptoBotPaymentMixin:
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
@@ -178,16 +149,14 @@ class CryptoBotPaymentMixin:
try:
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
amount_rubles_rounded = math.ceil(amount_rubles)
amount_kopeks = int(amount_rubles_rounded * 100)
amount_kopeks = int(amount_rubles * 100)
conversion_rate = (
amount_rubles / amount_usd if amount_usd > 0 else 0
)
logger.info(
"Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)",
"Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
amount_usd,
amount_rubles,
amount_rubles_rounded,
conversion_rate,
)
except Exception as error:
@@ -197,8 +166,7 @@ class CryptoBotPaymentMixin:
error,
)
amount_rubles = amount_usd
amount_rubles_rounded = math.ceil(amount_rubles)
amount_kopeks = int(amount_rubles_rounded * 100)
amount_kopeks = int(amount_usd * 100)
conversion_rate = 1.0
if amount_kopeks <= 0:
@@ -217,7 +185,7 @@ class CryptoBotPaymentMixin:
amount_kopeks=amount_kopeks,
description=(
"Пополнение через CryptoBot "
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles_rounded:.2f}₽)"
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles:.2f}₽)"
),
payment_method=PaymentMethod.CRYPTOBOT,
external_id=invoice_id,
@@ -243,6 +211,8 @@ class CryptoBotPaymentMixin:
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
@@ -271,41 +241,55 @@ class CryptoBotPaymentMixin:
await db.refresh(user)
admin_notification: Optional[_AdminNotificationContext] = None
user_notification: Optional[_UserNotificationPayload] = None
saved_cart_notification: Optional[_SavedCartNotificationPayload] = None
bot_instance = getattr(self, "bot", None)
if bot_instance:
admin_notification = _AdminNotificationContext(
user_id=user.id,
transaction_id=transaction.id,
old_balance=old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
message_text = (
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
from app.services.admin_notification_service import (
AdminNotificationService,
)
user_notification = _UserNotificationPayload(
telegram_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=keyboard,
amount_rubles=amount_rubles_rounded,
asset=updated_payment.asset,
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(
"Ошибка подготовки уведомления о пополнении CryptoBot: %s",
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
user.telegram_id,
f"{amount_rubles:.2f}",
updated_payment.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
@@ -321,7 +305,7 @@ class CryptoBotPaymentMixin:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=bot_instance,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
@@ -334,18 +318,21 @@ class CryptoBotPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and bot_instance:
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart"
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
@@ -356,35 +343,18 @@ class CryptoBotPaymentMixin:
callback_data="back_to_menu"
)]
])
saved_cart_notification = _SavedCartNotificationPayload(
telegram_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
),
reply_markup=keyboard,
user_id=user.id,
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
if admin_notification:
await self._deliver_admin_topup_notification(admin_notification)
if user_notification and bot_instance:
await self._deliver_user_topup_notification(user_notification)
if saved_cart_notification and bot_instance:
await self._deliver_saved_cart_reminder(saved_cart_notification)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
return True
@@ -394,116 +364,6 @@ class CryptoBotPaymentMixin:
)
return False
async def _deliver_admin_topup_notification(
self, context: _AdminNotificationContext
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.user import get_user_by_id
from app.database.crud.transaction import get_transaction_by_id
except Exception as error:
logger.error(
"Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s",
error,
exc_info=True,
)
return
async with AsyncSessionLocal() as session:
try:
user = await get_user_by_id(session, context.user_id)
transaction = await get_transaction_by_id(session, context.transaction_id)
except Exception as error:
logger.error(
"Ошибка загрузки данных для админ-уведомления CryptoBot: %s",
error,
exc_info=True,
)
await session.rollback()
return
if not user or not transaction:
logger.warning(
"Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s",
bool(user),
bool(transaction),
)
return
notification_service = AdminNotificationService(bot_instance)
try:
await notification_service.send_balance_topup_notification(
user,
transaction,
context.old_balance,
topup_status=context.topup_status,
referrer_info=context.referrer_info,
subscription=getattr(user, "subscription", None),
promo_group=getattr(user, "promo_group", None),
db=session,
)
except Exception as error:
logger.error(
"Ошибка отправки админ-уведомления о пополнении CryptoBot: %s",
error,
exc_info=True,
)
async def _deliver_user_topup_notification(
self, payload: _UserNotificationPayload
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
await bot_instance.send_message(
payload.telegram_id,
payload.text,
parse_mode=payload.parse_mode,
reply_markup=payload.reply_markup,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
payload.telegram_id,
f"{payload.amount_rubles:.2f}",
payload.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
async def _deliver_saved_cart_reminder(
self, payload: _SavedCartNotificationPayload
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
await bot_instance.send_message(
chat_id=payload.telegram_id,
text=payload.text,
reply_markup=payload.reply_markup,
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
payload.user_id,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s",
payload.user_id,
error,
exc_info=True,
)
async def get_cryptobot_payment_status(
self,
db: AsyncSession,
+1 -1
View File
@@ -335,7 +335,7 @@ class HeleketPaymentMixin:
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
referrer_info = format_referrer_info(user)
subscription = getattr(user, "subscription", None)
promo_group = user.get_primary_promo_group()
promo_group = getattr(user, "promo_group", None)
try:
from app.services.admin_notification_service import AdminNotificationService
+4 -18
View File
@@ -247,7 +247,6 @@ class MulenPayPaymentMixin:
user,
payment.amount_kopeks,
f"Пополнение {display_name}: {payment.amount_kopeks // 100}",
create_transaction=False,
)
try:
@@ -270,18 +269,9 @@ class MulenPayPaymentMixin:
user.has_made_first_topup = True
await db.commit()
# После коммита отношения пользователя могли быть сброшены, поэтому
# повторно загружаем пользователя с предзагрузкой зависимостей
user = await payment_module.get_user_by_id(db, user.id)
if not user:
logger.error(
"Пользователь %s не найден при повторной загрузке после %s",
payment.user_id,
display_name,
)
return False
await db.refresh(user)
promo_group = user.get_primary_promo_group()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
@@ -375,7 +365,7 @@ class MulenPayPaymentMixin:
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart"
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
@@ -389,11 +379,7 @@ class MulenPayPaymentMixin:
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(
+45 -185
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class Pal24PaymentMixin:
"""Mixin с созданием счетов Pal24, обработкой callback и запросом статуса."""
"""Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
async def create_pal24_payment(
self,
@@ -197,12 +197,12 @@ class Pal24PaymentMixin:
"payment_url": primary_link,
}
async def process_pal24_callback(
async def process_pal24_postback(
self,
db: AsyncSession,
callback: Dict[str, Any],
postback: Dict[str, Any],
) -> bool:
"""Обрабатывает callback от Pal24 и начисляет баланс при успехе."""
"""Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
try:
payment_module = import_module("app.services.payment_service")
@@ -213,26 +213,26 @@ class Pal24PaymentMixin:
return None
payment_id = _first_non_empty(
callback.get("id"),
callback.get("TrsId"),
callback.get("TrsID"),
postback.get("id"),
postback.get("TrsId"),
postback.get("TrsID"),
)
bill_id = _first_non_empty(
callback.get("bill_id"),
callback.get("billId"),
callback.get("BillId"),
callback.get("BillID"),
postback.get("bill_id"),
postback.get("billId"),
postback.get("BillId"),
postback.get("BillID"),
)
order_id = _first_non_empty(
callback.get("order_id"),
callback.get("orderId"),
callback.get("InvId"),
callback.get("InvID"),
postback.get("order_id"),
postback.get("orderId"),
postback.get("InvId"),
postback.get("InvID"),
)
status = (callback.get("status") or callback.get("Status") or "").upper()
status = (postback.get("status") or postback.get("Status") or "").upper()
if not bill_id and not order_id:
logger.error("Pal24 callback без идентификаторов: %s", callback)
logger.error("Pal24 postback без идентификаторов: %s", postback)
return False
payment = None
@@ -250,65 +250,57 @@ class Pal24PaymentMixin:
return True
if status in {"PAID", "SUCCESS", "OVERPAID"}:
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
is_paid=True,
paid_at=datetime.utcnow(),
callback_payload=callback,
postback_payload=postback,
payment_id=payment_id,
payment_status=callback.get("Status") or status,
payment_status=postback.get("Status") or status,
payment_method=(
callback.get("payment_method")
or callback.get("PaymentMethod")
or metadata.get("selected_method")
postback.get("payment_method")
or postback.get("PaymentMethod")
or (payment.metadata_json or {}).get("selected_method")
or getattr(payment, "payment_method", None)
),
balance_amount=callback.get("BalanceAmount")
or callback.get("balance_amount"),
balance_currency=callback.get("BalanceCurrency")
or callback.get("balance_currency"),
payer_account=callback.get("AccountNumber")
or callback.get("account")
or callback.get("Account"),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
)
return await self._finalize_pal24_payment(
db,
payment,
payment_id=payment_id,
trigger="callback",
trigger="postback",
)
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
await payment_module.update_pal24_payment_status(
db,
payment,
status=status or "UNKNOWN",
is_paid=False,
callback_payload=callback,
postback_payload=postback,
payment_id=payment_id,
payment_status=callback.get("Status") or status,
payment_status=postback.get("Status") or status,
payment_method=(
callback.get("payment_method")
or callback.get("PaymentMethod")
postback.get("payment_method")
or postback.get("PaymentMethod")
or getattr(payment, "payment_method", None)
),
balance_amount=callback.get("BalanceAmount")
or callback.get("balance_amount"),
balance_currency=callback.get("BalanceCurrency")
or callback.get("balance_currency"),
payer_account=callback.get("AccountNumber")
or callback.get("account")
or callback.get("Account"),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
)
logger.info(
"Обновили Pal24 платеж %s до статуса %s",
@@ -318,7 +310,7 @@ class Pal24PaymentMixin:
return True
except Exception as error:
logger.error("Ошибка обработки Pal24 callback: %s", error, exc_info=True)
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
return False
async def _finalize_pal24_payment(
@@ -370,7 +362,7 @@ class Pal24PaymentMixin:
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = user.get_primary_promo_group()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
@@ -481,7 +473,7 @@ class Pal24PaymentMixin:
"BALANCE_TOPUP_CART_BUTTON",
"🛒 Продолжить оформление",
),
callback_data="return_to_saved_cart",
callback_data="subscription_resume_checkout",
)
],
[
@@ -497,11 +489,7 @@ class Pal24PaymentMixin:
chat_id=user.telegram_id,
text=(
"✅ Баланс пополнен на "
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
f"{settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}"
),
reply_markup=keyboard,
)
@@ -693,34 +681,12 @@ class Pal24PaymentMixin:
exc_info=True,
)
links_map, selected_method = self._build_links_map(payment, remote_payloads)
primary_url = (
links_map.get(selected_method)
or links_map.get("sbp")
or links_map.get("page")
or links_map.get("card")
)
secondary_url = (
links_map.get("page")
or links_map.get("card")
or links_map.get("sbp")
)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status": remote_status_for_return,
"remote_data": remote_data,
"links": links_map or None,
"primary_url": primary_url,
"secondary_url": secondary_url,
"sbp_url": links_map.get("sbp"),
"card_url": links_map.get("card"),
"link_page_url": links_map.get("page")
or getattr(payment, "link_page_url", None),
"link_url": getattr(payment, "link_url", None),
"selected_method": selected_method,
}
except Exception as error:
@@ -871,112 +837,6 @@ class Pal24PaymentMixin:
normalized = payment_method.strip().lower()
return mapping.get(normalized, "sbp")
@staticmethod
def _pick_first(mapping: Dict[str, Any], *keys: str) -> Optional[str]:
for key in keys:
value = mapping.get(key)
if value:
return str(value)
return None
@classmethod
def _build_links_map(
cls,
payment: Any,
remote_payloads: Dict[str, Any],
) -> tuple[Dict[str, str], str]:
links: Dict[str, str] = {}
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
if metadata:
links_meta = metadata.get("links")
if isinstance(links_meta, dict):
for key, value in links_meta.items():
if value:
links[key] = str(value)
selected_method = cls._normalize_payment_method(
(metadata.get("selected_method") if isinstance(metadata, dict) else None)
or getattr(payment, "payment_method", None)
)
def _visit(value: Any) -> List[Dict[str, Any]]:
stack: List[Any] = [value]
result: List[Dict[str, Any]] = []
while stack:
current = stack.pop()
if isinstance(current, dict):
result.append(current)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return result
payload_sources: List[Any] = []
if metadata:
payload_sources.append(metadata.get("raw_response"))
payload_sources.append(getattr(payment, "callback_payload", None))
payload_sources.extend(remote_payloads.values())
sbp_keys = (
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_keys = (
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
page_keys = (
"link_page_url",
"linkPageUrl",
"page_url",
"pageUrl",
)
for source in payload_sources:
if not source:
continue
for candidate in _visit(source):
sbp_url = cls._pick_first(candidate, *sbp_keys)
if sbp_url and "sbp" not in links:
links["sbp"] = sbp_url
card_url = cls._pick_first(candidate, *card_keys)
if card_url and "card" not in links:
links["card"] = card_url
page_url = cls._pick_first(candidate, *page_keys)
if page_url and "page" not in links:
links["page"] = page_url
if getattr(payment, "link_page_url", None):
links.setdefault("page", str(payment.link_page_url))
if getattr(payment, "link_url", None):
link_url_value = str(payment.link_url)
if selected_method == "card":
links.setdefault("card", link_url_value)
else:
links.setdefault("sbp", link_url_value)
return links, selected_method
@staticmethod
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
-536
View File
@@ -1,536 +0,0 @@
"""Mixin для интеграции платежей Platega."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
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.platega_service import PlategaService
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class PlategaPaymentMixin:
"""Логика создания и обработки платежей Platega."""
_SUCCESS_STATUSES = {"CONFIRMED"}
_FAILED_STATUSES = {"FAILED", "CANCELED", "EXPIRED"}
_PENDING_STATUSES = {"PENDING", "INPROGRESS"}
async def create_platega_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str,
language: str,
payment_method_code: int,
) -> Optional[Dict[str, Any]]:
service: Optional[PlategaService] = getattr(self, "platega_service", None)
if not service or not service.is_configured:
logger.error("Platega сервис не инициализирован")
return None
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма Platega меньше минимальной: %s < %s",
amount_kopeks,
settings.PLATEGA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма Platega больше максимальной: %s > %s",
amount_kopeks,
settings.PLATEGA_MAX_AMOUNT_KOPEKS,
)
return None
correlation_id = uuid.uuid4().hex
payload_token = f"platega:{correlation_id}"
amount_value = amount_kopeks / 100
try:
response = await service.create_payment(
payment_method=payment_method_code,
amount=amount_value,
currency=settings.PLATEGA_CURRENCY,
description=description,
return_url=settings.get_platega_return_url(),
failed_url=settings.get_platega_failed_url(),
payload=payload_token,
)
except Exception as error: # pragma: no cover - network errors
logger.exception("Ошибка Platega при создании платежа: %s", error)
return None
if not response:
logger.error("Platega вернул пустой ответ при создании платежа")
return None
transaction_id = response.get("transactionId") or response.get("id")
redirect_url = response.get("redirect")
status = str(response.get("status") or "PENDING").upper()
expires_at = PlategaService.parse_expires_at(response.get("expiresIn"))
metadata = {
"raw_response": response,
"language": language,
"selected_method": payment_method_code,
}
payment_module = import_module("app.services.payment_service")
payment = await payment_module.create_platega_payment(
db,
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=settings.PLATEGA_CURRENCY,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=transaction_id,
redirect_url=redirect_url,
return_url=settings.get_platega_return_url(),
failed_url=settings.get_platega_failed_url(),
payload=payload_token,
metadata=metadata,
expires_at=expires_at,
)
logger.info(
"Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)",
transaction_id or payment.id,
user_id,
payment_method_code,
amount_value,
)
return {
"local_payment_id": payment.id,
"transaction_id": transaction_id,
"redirect_url": redirect_url,
"status": status,
"expires_at": expires_at,
"correlation_id": correlation_id,
}
async def process_platega_webhook(
self,
db: AsyncSession,
payload: Dict[str, Any],
) -> bool:
payment_module = import_module("app.services.payment_service")
transaction_id = str(payload.get("id") or "").strip()
payload_token = payload.get("payload")
payment = None
if transaction_id:
payment = await payment_module.get_platega_payment_by_transaction_id(
db, transaction_id
)
if not payment and payload_token:
payment = await payment_module.get_platega_payment_by_correlation_id(
db, str(payload_token).replace("platega:", "")
)
if not payment:
logger.warning("Platega webhook: платеж не найден (id=%s)", transaction_id)
return False
status_raw = str(payload.get("status") or "").upper()
if not status_raw:
logger.warning("Platega webhook без статуса для платежа %s", payment.id)
return False
update_kwargs = {
"status": status_raw,
"callback_payload": payload,
}
if transaction_id:
update_kwargs["platega_transaction_id"] = transaction_id
if status_raw in self._SUCCESS_STATUSES:
if payment.is_paid:
logger.info(
"Platega платеж %s уже помечен как оплачен", payment.correlation_id
)
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=True,
)
return True
payment = await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
await self._finalize_platega_payment(db, payment, payload)
return True
if status_raw in self._FAILED_STATUSES:
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=False,
)
logger.info(
"Platega платеж %s перешёл в статус %s", payment.correlation_id, status_raw
)
return True
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
return True
async def get_platega_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if not payment:
return None
service: Optional[PlategaService] = getattr(self, "platega_service", None)
remote_status: Optional[str] = None
remote_payload: Optional[Dict[str, Any]] = None
if service and payment.platega_transaction_id:
try:
remote_payload = await service.get_transaction(
payment.platega_transaction_id
)
except Exception as error: # pragma: no cover - network errors
logger.error(
"Ошибка Platega при получении транзакции %s: %s",
payment.platega_transaction_id,
error,
)
if remote_payload:
remote_status = str(remote_payload.get("status") or "").upper()
if remote_status and remote_status != payment.status:
await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
metadata={
**(getattr(payment, "metadata_json", {}) or {}),
"remote_status": remote_payload,
},
)
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if (
remote_status in self._SUCCESS_STATUSES
and not payment.is_paid
):
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
callback_payload=remote_payload,
)
await self._finalize_platega_payment(db, payment, remote_payload)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote": remote_payload,
}
async def _finalize_platega_payment(
self,
db: AsyncSession,
payment: Any,
payload: Optional[Dict[str, Any]],
) -> Any:
payment_module = import_module("app.services.payment_service")
metadata = dict(getattr(payment, "metadata_json", {}) or {})
if payload is not None:
metadata["webhook"] = payload
paid_at = None
if isinstance(payload, dict):
paid_at_raw = payload.get("paidAt") or payload.get("confirmedAt")
if paid_at_raw:
try:
paid_at = datetime.fromisoformat(str(paid_at_raw))
except ValueError:
paid_at = None
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status="CONFIRMED",
is_paid=True,
paid_at=paid_at,
metadata=metadata,
callback_payload=payload,
)
locked_payment = await payment_module.get_platega_payment_by_id_for_update(
db, payment.id
)
if locked_payment:
payment = locked_payment
metadata = dict(getattr(payment, "metadata_json", {}) or {})
balance_already_credited = bool(metadata.get("balance_credited"))
if payment.transaction_id:
logger.info(
"Platega платеж %s уже связан с транзакцией %s",
payment.correlation_id,
payment.transaction_id,
)
return payment
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error("Пользователь %s не найден для Platega", payment.user_id)
return payment
transaction_external_id = (
str(payload.get("id"))
if isinstance(payload, dict) and payload.get("id")
else payment.platega_transaction_id
)
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.PLATEGA,
)
method_display = settings.get_platega_method_display_name(payment.payment_method_code)
description = (
f"Пополнение через Platega ({method_display})"
if method_display
else "Пополнение через Platega"
)
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.PLATEGA,
external_id=transaction_external_id or payment.correlation_id,
is_completed=True,
)
created_transaction = True
await payment_module.link_platega_payment_to_transaction(
db, payment=payment, transaction_id=transaction.id
)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info(
"Platega платеж %s уже зачислил баланс ранее",
payment.correlation_id,
)
return payment
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()
await db.commit()
await db.refresh(user)
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 "🔄 Пополнение"
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("Ошибка обработки реферального пополнения Platega: %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)
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("Ошибка отправки админ уведомления Platega: %s", error)
method_title = settings.get_platega_method_display_title(payment.payment_method_code)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"🦊 Способ: {method_title}\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error("Ошибка отправки уведомления пользователю Platega: %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
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
f"{cart_message}"
),
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
payment.user_id,
error,
exc_info=True,
)
metadata["balance_change"] = {
"old_balance": old_balance,
"new_balance": user.balance_kopeks,
"credited_at": datetime.utcnow().isoformat(),
}
metadata["balance_credited"] = True
await payment_module.update_platega_payment(
db,
payment=payment,
metadata=metadata,
)
logger.info(
"✅ Обработан Platega платеж %s для пользователя %s",
payment.correlation_id,
payment.user_id,
)
return payment
+3 -7
View File
@@ -430,7 +430,7 @@ class TelegramStarsMixin:
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = user.get_primary_promo_group()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
@@ -584,7 +584,7 @@ class TelegramStarsMixin:
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
callback_data="subscription_resume_checkout",
)
],
[
@@ -604,11 +604,7 @@ class TelegramStarsMixin:
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard,
)
logger.info(
+2 -6
View File
@@ -464,7 +464,7 @@ class WataPaymentMixin:
await db.commit()
await db.refresh(user)
promo_group = user.get_primary_promo_group()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
@@ -514,10 +514,6 @@ class WataPaymentMixin:
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: WATA\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
@@ -565,7 +561,7 @@ class WataPaymentMixin:
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
callback_data="subscription_resume_checkout",
)
],
[
+28 -360
View File
@@ -30,66 +30,6 @@ if TYPE_CHECKING:
class YooKassaPaymentMixin:
"""Mixin с операциями по созданию и подтверждению платежей YooKassa."""
@staticmethod
def _format_amount_value(value: Any) -> str:
"""Форматирует сумму для хранения в webhook-объекте."""
try:
quantized = Decimal(str(value)).quantize(Decimal("0.00"))
return format(quantized, "f")
except (InvalidOperation, ValueError, TypeError):
return str(value)
@classmethod
def _merge_remote_yookassa_payload(
cls,
event_object: Dict[str, Any],
remote_data: Dict[str, Any],
) -> Dict[str, Any]:
"""Объединяет локальные данные вебхука с ответом API YooKassa."""
merged: Dict[str, Any] = dict(event_object)
status = remote_data.get("status")
if status:
merged["status"] = status
if "paid" in remote_data:
merged["paid"] = bool(remote_data.get("paid"))
if "refundable" in remote_data:
merged["refundable"] = bool(remote_data.get("refundable"))
payment_method_type = remote_data.get("payment_method_type")
if payment_method_type:
payment_method = dict(merged.get("payment_method") or {})
payment_method["type"] = payment_method_type
merged["payment_method"] = payment_method
amount_value = remote_data.get("amount_value")
amount_currency = remote_data.get("amount_currency")
if amount_value is not None or amount_currency:
merged_amount = dict(merged.get("amount") or {})
if amount_value is not None:
merged_amount["value"] = cls._format_amount_value(amount_value)
if amount_currency:
merged_amount["currency"] = str(amount_currency).upper()
merged["amount"] = merged_amount
for datetime_field in ("captured_at", "created_at"):
value = remote_data.get(datetime_field)
if value:
merged[datetime_field] = value
metadata = remote_data.get("metadata")
if metadata:
try:
merged["metadata"] = dict(metadata) # type: ignore[arg-type]
except TypeError:
merged["metadata"] = metadata
return merged
async def create_yookassa_payment(
self,
db: AsyncSession,
@@ -379,50 +319,9 @@ class YooKassaPaymentMixin:
) -> bool:
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
try:
from sqlalchemy import select
payment_module = import_module("app.services.payment_service")
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
if existing_transaction:
# Если транзакция уже существует, просто завершаем обработку
logger.info(
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
payment.yookassa_payment_id,
existing_transaction.id,
)
# Убедимся, что платеж связан с транзакцией
if not getattr(payment, "transaction_id", None):
try:
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
existing_transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(
linked_payment,
"transaction_id",
existing_transaction.id,
)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
except Exception as link_error: # pragma: no cover - защитный лог
logger.warning(
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
payment.yookassa_payment_id,
existing_transaction.id,
link_error,
exc_info=True,
)
return True
payment_description = getattr(payment, "description", "YooKassa платеж")
payment_metadata: Dict[str, Any] = {}
try:
@@ -437,84 +336,6 @@ class YooKassaPaymentMixin:
except Exception as parse_error:
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
processing_completed = bool(payment_metadata.get("processing_completed"))
transaction = None
existing_transaction_id = getattr(payment, "transaction_id", None)
if existing_transaction_id:
try:
from app.database.crud.transaction import get_transaction_by_id
transaction = await get_transaction_by_id(db, existing_transaction_id)
except Exception as fetch_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось получить транзакцию %s для платежа YooKassa %s: %s",
existing_transaction_id,
payment.yookassa_payment_id,
fetch_error,
exc_info=True,
)
if transaction and processing_completed:
logger.info(
"Пропускаем повторную обработку платежа YooKassa %s: транзакция %s уже завершила начисление.",
payment.yookassa_payment_id,
existing_transaction_id,
)
return True
if transaction:
logger.info(
"Транзакция %s для платежа YooKassa %s найдена, но обработка ранее не была завершена — повторяем критические шаги.",
existing_transaction_id,
payment.yookassa_payment_id,
)
if transaction is None:
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
if existing_transaction:
# Если транзакция уже существует, пропускаем обработку
logger.info(
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
payment.yookassa_payment_id,
existing_transaction.id,
)
# Убедимся, что платеж связан с транзакцией
if not getattr(payment, "transaction_id", None):
try:
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
existing_transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(
linked_payment,
"transaction_id",
existing_transaction.id,
)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
except Exception as link_error: # pragma: no cover - защитный лог
logger.warning(
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
payment.yookassa_payment_id,
existing_transaction.id,
link_error,
exc_info=True,
)
return True
payment_description = getattr(payment, "description", "YooKassa платеж")
payment_purpose = payment_metadata.get("payment_purpose", "")
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
@@ -529,32 +350,27 @@ class YooKassaPaymentMixin:
else f"Пополнение через YooKassa: {payment_description}"
)
if transaction is None:
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=transaction_type,
amount_kopeks=payment.amount_kopeks,
description=transaction_description,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
)
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=transaction_type,
amount_kopeks=payment.amount_kopeks,
description=transaction_description,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
)
if not getattr(payment, "transaction_id", None):
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
critical_flow_completed = False
processing_marked = False
if linked_payment:
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
@@ -571,39 +387,13 @@ class YooKassaPaymentMixin:
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
# Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой
from app.database.crud.user import get_user_by_id
from sqlalchemy.orm import selectinload
from app.database.models import User, Subscription as SubscriptionModel
# Загружаем пользователя с подпиской и промо-группой
full_user_result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(selectinload(User.user_promo_groups))
.where(User.id == user.id)
)
full_user = full_user_result.scalar_one_or_none()
# Используем обновленные данные или исходные, если не удалось обновить
subscription = full_user.subscription if full_user else getattr(user, "subscription", None)
promo_group = full_user.get_primary_promo_group() if full_user else (user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None)
# Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой
user_for_referrer = full_user if full_user else user
referrer_info = format_referrer_info(user_for_referrer)
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
payment_metadata = await self._mark_yookassa_payment_processing_completed(
db,
payment,
payment_metadata,
commit=False,
)
processing_marked = True
await db.commit()
try:
@@ -635,13 +425,8 @@ class YooKassaPaymentMixin:
)
notification_service = AdminNotificationService(self.bot)
# Обновляем пользователя, чтобы избежать проблем с ленивой загрузкой
from app.database.crud.user import get_user_by_id
refreshed_user = await get_user_by_id(db, user.id)
await notification_service.send_balance_topup_notification(
refreshed_user or user,
user,
transaction,
old_balance,
topup_status=topup_status,
@@ -725,7 +510,7 @@ class YooKassaPaymentMixin:
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
callback_data="subscription_resume_checkout",
)
],
[
@@ -745,11 +530,7 @@ class YooKassaPaymentMixin:
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard,
)
logger.info(
@@ -838,32 +619,10 @@ class YooKassaPaymentMixin:
)
notification_service = AdminNotificationService(self.bot)
# Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database.models import User, Subscription as SubscriptionModel
# Загружаем пользователя с подпиской и промо-группой
full_user_result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(selectinload(User.user_promo_groups))
.where(User.id == user.id)
)
full_user = full_user_result.scalar_one_or_none()
# Загружаем подписку отдельно, если нужно
subscription_result = await db.execute(
select(SubscriptionModel)
.where(SubscriptionModel.user_id == user.id)
)
subscription_db = subscription_result.scalar_one_or_none()
await notification_service.send_subscription_purchase_notification(
db,
full_user or user,
subscription_db or subscription,
user,
subscription,
transaction,
subscription_period,
was_trial_conversion=False,
@@ -879,31 +638,6 @@ class YooKassaPaymentMixin:
except Exception as e:
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
if not processing_marked:
payment_metadata = await self._mark_yookassa_payment_processing_completed(
db,
payment,
payment_metadata,
commit=True,
)
processing_marked = True
critical_flow_completed = True
else:
logger.warning(
"Пользователь %s для платежа YooKassa %s не найден — начисление баланса невозможно",
payment.user_id,
payment.yookassa_payment_id,
)
if critical_flow_completed and not processing_marked:
payment_metadata = await self._mark_yookassa_payment_processing_completed(
db,
payment,
payment_metadata,
commit=True,
)
if is_simple_subscription:
logger.info(
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s",
@@ -929,46 +663,6 @@ class YooKassaPaymentMixin:
)
return False
async def _mark_yookassa_payment_processing_completed(
self,
db: AsyncSession,
payment: "YooKassaPayment",
payment_metadata: Dict[str, Any],
*,
commit: bool = False,
) -> Dict[str, Any]:
"""Отмечает платёж как полностью обработанный, чтобы избежать повторного начисления."""
if payment_metadata.get("processing_completed"):
return payment_metadata
updated_metadata = dict(payment_metadata)
updated_metadata["processing_completed"] = True
try:
from sqlalchemy import update
from app.database.models import YooKassaPayment as YooKassaPaymentModel
await db.execute(
update(YooKassaPaymentModel)
.where(YooKassaPaymentModel.id == payment.id)
.values(metadata_json=updated_metadata, updated_at=datetime.utcnow())
)
if commit:
await db.commit()
else:
await db.flush()
payment.metadata_json = updated_metadata
except Exception as mark_error: # pragma: no cover - защитный лог
logger.warning(
"Не удалось отметить платеж YooKassa %s как завершенный: %s",
payment.yookassa_payment_id,
mark_error,
exc_info=True,
)
return updated_metadata
async def process_yookassa_webhook(
self,
db: AsyncSession,
@@ -982,32 +676,6 @@ class YooKassaPaymentMixin:
logger.warning("Webhook без payment id: %s", event)
return False
remote_data: Optional[Dict[str, Any]] = None
if getattr(self, "yookassa_service", None):
try:
remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
yookassa_payment_id
)
except Exception as error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось запросить актуальный статус платежа YooKassa %s: %s",
yookassa_payment_id,
error,
exc_info=True,
)
if remote_data:
previous_status = event_object.get("status")
event_object = self._merge_remote_yookassa_payload(event_object, remote_data)
if previous_status and event_object.get("status") != previous_status:
logger.info(
"Статус платежа YooKassa %s скорректирован по данным API: %s%s",
yookassa_payment_id,
previous_status,
event_object.get("status"),
)
event["object"] = event_object
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
+1 -48
View File
@@ -15,13 +15,11 @@ from app.external.heleket import HeleketService
from app.external.telegram_stars import TelegramStarsService
from app.services.mulenpay_service import MulenPayService
from app.services.pal24_service import Pal24Service
from app.services.platega_service import PlategaService
from app.services.payment import (
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PlategaPaymentMixin,
PaymentCommonMixin,
TelegramStarsMixin,
TributePaymentMixin,
@@ -67,11 +65,6 @@ async def create_transaction(*args, **kwargs):
return await transaction_crud.create_transaction(*args, **kwargs)
async def get_transaction_by_external_id(*args, **kwargs):
transaction_crud = import_module("app.database.crud.transaction")
return await transaction_crud.get_transaction_by_external_id(*args, **kwargs)
async def add_user_balance(*args, **kwargs):
user_crud = import_module("app.database.crud.user")
return await user_crud.add_user_balance(*args, **kwargs)
@@ -177,41 +170,6 @@ async def link_wata_payment_to_transaction(*args, **kwargs):
return await wata_crud.link_wata_payment_to_transaction(*args, **kwargs)
async def create_platega_payment(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.create_platega_payment(*args, **kwargs)
async def get_platega_payment_by_id(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_id(*args, **kwargs)
async def get_platega_payment_by_id_for_update(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_id_for_update(*args, **kwargs)
async def get_platega_payment_by_transaction_id(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_transaction_id(*args, **kwargs)
async def get_platega_payment_by_correlation_id(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_correlation_id(*args, **kwargs)
async def update_platega_payment(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.update_platega_payment(*args, **kwargs)
async def link_platega_payment_to_transaction(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.link_platega_payment_to_transaction(*args, **kwargs)
async def create_cryptobot_payment(*args, **kwargs):
crypto_crud = import_module("app.database.crud.cryptobot")
return await crypto_crud.create_cryptobot_payment(*args, **kwargs)
@@ -266,7 +224,6 @@ class PaymentService(
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PlategaPaymentMixin,
WataPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -291,14 +248,11 @@ class PaymentService(
self.pal24_service = (
Pal24Service() if settings.is_pal24_enabled() else None
)
self.platega_service = (
PlategaService() if settings.is_platega_enabled() else None
)
self.wata_service = WataService() if settings.is_wata_enabled() else None
mulenpay_name = settings.get_mulenpay_display_name()
logger.debug(
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s)",
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)",
bool(self.yookassa_service),
bool(self.stars_service),
bool(self.cryptobot_service),
@@ -306,6 +260,5 @@ class PaymentService(
mulenpay_name,
bool(self.mulenpay_service),
bool(self.pal24_service),
bool(self.platega_service),
bool(self.wata_service),
)
@@ -21,7 +21,6 @@ from app.database.models import (
HeleketPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
PaymentMethod,
Transaction,
TransactionType,
@@ -63,7 +62,6 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.WATA,
PaymentMethod.HELEKET,
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
}
)
@@ -75,7 +73,6 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.PAL24,
PaymentMethod.WATA,
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
}
)
@@ -89,8 +86,6 @@ def method_display_name(method: PaymentMethod) -> str:
return "YooKassa"
if method == PaymentMethod.WATA:
return "WATA"
if method == PaymentMethod.PLATEGA:
return "Platega"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.HELEKET:
@@ -109,8 +104,6 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_pal24_enabled()
if method == PaymentMethod.WATA:
return settings.is_wata_enabled()
if method == PaymentMethod.PLATEGA:
return settings.is_platega_enabled()
if method == PaymentMethod.CRYPTOBOT:
return settings.is_cryptobot_enabled()
if method == PaymentMethod.HELEKET:
@@ -322,13 +315,6 @@ def _is_wata_pending(payment: WataPayment) -> bool:
}
def _is_platega_pending(payment: PlategaPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or "").lower()
return status in {"pending", "inprogress", "in_progress"}
def _is_heleket_pending(payment: HeleketPayment) -> bool:
if payment.is_paid:
return False
@@ -473,33 +459,6 @@ async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[Pendi
return records
async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(PlategaPayment)
.options(selectinload(PlategaPayment.user))
.where(PlategaPayment.created_at >= cutoff)
.order_by(desc(PlategaPayment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
if not _is_platega_pending(payment):
continue
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
record = _build_record(
PaymentMethod.PLATEGA,
payment,
identifier=identifier,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if record:
records.append(record)
return records
async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(HeleketPayment)
@@ -623,7 +582,6 @@ async def list_recent_pending_payments(
await _fetch_pal24_payments(db, cutoff),
await _fetch_mulenpay_payments(db, cutoff),
await _fetch_wata_payments(db, cutoff),
await _fetch_platega_payments(db, cutoff),
await _fetch_heleket_payments(db, cutoff),
await _fetch_cryptobot_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
@@ -690,22 +648,6 @@ async def get_payment_record(
expires_at=getattr(payment, "expires_at", None),
)
if method == PaymentMethod.PLATEGA:
payment = await db.get(PlategaPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
return _build_record(
method,
payment,
identifier=identifier,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if method == PaymentMethod.HELEKET:
payment = await db.get(HeleketPayment, local_payment_id)
if not payment:
@@ -790,9 +732,6 @@ async def run_manual_check(
elif method == PaymentMethod.WATA:
result = await payment_service.get_wata_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.PLATEGA:
result = await payment_service.get_platega_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.HELEKET:
payment = await payment_service.sync_heleket_payment_status(
db, local_payment_id=local_payment_id
-190
View File
@@ -1,190 +0,0 @@
"""HTTP-интеграция с Platega API."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class PlategaService:
"""Обертка над Platega API с базовой повторной отправкой запросов."""
def __init__(self) -> None:
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
self.merchant_id = settings.PLATEGA_MERCHANT_ID
self.secret = settings.PLATEGA_SECRET
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
self._max_retries = 3
self._retry_delay = 0.5
self._retryable_statuses = {500, 502, 503, 504}
@property
def is_configured(self) -> bool:
return settings.is_platega_enabled()
async def create_payment(
self,
*,
payment_method: int,
amount: float,
currency: str,
description: Optional[str] = None,
return_url: Optional[str] = None,
failed_url: Optional[str] = None,
payload: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
body: Dict[str, Any] = {
"paymentMethod": payment_method,
"paymentDetails": {
"amount": round(amount, 2),
"currency": currency,
},
}
if description:
body["description"] = description
if return_url:
body["return"] = return_url
if failed_url:
body["failedUrl"] = failed_url
if payload:
body["payload"] = payload
return await self._request("POST", "/transaction/process", json_data=body)
async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]:
endpoint = f"/transaction/{transaction_id}"
return await self._request("GET", endpoint)
async def _request(
self,
method: str,
endpoint: str,
*,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("Platega service is not configured")
return None
url = f"{self.base_url}{endpoint}"
headers = {
"X-MerchantId": self.merchant_id or "",
"X-Secret": self.secret or "",
"Content-Type": "application/json",
}
last_error: Optional[BaseException] = None
for attempt in range(1, self._max_retries + 1):
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
async with session.request(
method,
url,
json=json_data,
params=params,
headers=headers,
) as response:
data, raw_text = await self._deserialize_response(response)
if response.status >= 400:
logger.error(
"Platega API error %s %s: %s",
response.status,
endpoint,
raw_text,
)
if (
response.status in self._retryable_statuses
and attempt < self._max_retries
):
await asyncio.sleep(self._retry_delay * attempt)
continue
return None
return data
except asyncio.CancelledError:
logger.debug("Platega request cancelled: %s %s", method, endpoint)
raise
except asyncio.TimeoutError as error:
last_error = error
logger.warning(
"Platega request timeout (%s %s) attempt %s/%s",
method,
endpoint,
attempt,
self._max_retries,
)
except aiohttp.ClientError as error:
last_error = error
logger.warning(
"Platega client error (%s %s) attempt %s/%s: %s",
method,
endpoint,
attempt,
self._max_retries,
error,
)
except Exception as error: # pragma: no cover - safety
logger.exception("Unexpected Platega error: %s", error)
return None
if attempt < self._max_retries:
await asyncio.sleep(self._retry_delay * attempt)
if last_error is not None:
logger.error(
"Platega request failed after %s attempts (%s %s): %s",
self._max_retries,
method,
endpoint,
last_error,
)
return None
@staticmethod
async def _deserialize_response(
response: aiohttp.ClientResponse,
) -> tuple[Optional[Dict[str, Any]], str]:
raw_text = await response.text()
if not raw_text:
return None, ""
content_type = response.headers.get("Content-Type", "")
if "json" in content_type.lower() or not content_type:
try:
return json.loads(raw_text), raw_text
except json.JSONDecodeError as error:
logger.error(
"Failed to decode Platega JSON response %s: %s",
response.url,
error,
)
return None, raw_text
return None, raw_text
@staticmethod
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
if not expires_in:
return None
try:
hours, minutes, seconds = [int(part) for part in expires_in.split(":", 2)]
delta = timedelta(hours=hours, minutes=minutes, seconds=seconds)
return datetime.utcnow() + delta
except Exception:
logger.warning("Failed to parse Platega expiresIn value: %s", expires_in)
return None
+62 -94
View File
@@ -68,13 +68,17 @@ async def send_poll_to_users(
poll: Poll,
users: Iterable[User],
) -> dict:
from app.database.database import AsyncSessionLocal
sent = 0
failed = 0
skipped = 0
poll_id = poll.id
poll_snapshot = SimpleNamespace(
title=poll.title,
description=poll.description,
reward_enabled=poll.reward_enabled,
reward_amount_kopeks=poll.reward_amount_kopeks,
)
user_snapshots = [
SimpleNamespace(
@@ -85,108 +89,72 @@ async def send_poll_to_users(
for user in users
]
# Получаем список пользователей, которые уже прошли опрос, за один запрос
user_ids = [user_snapshot.id for user_snapshot in user_snapshots]
existing_responses_result = await db.execute(
select(PollResponse.user_id).where(
and_(
PollResponse.poll_id == poll_id,
PollResponse.user_id.in_(user_ids)
for index, user in enumerate(user_snapshots, start=1):
existing_response = await db.execute(
select(PollResponse.id).where(
and_(
PollResponse.poll_id == poll_id,
PollResponse.user_id == user.id,
)
)
)
)
existing_user_ids = set(existing_responses_result.scalars().all())
if existing_response.scalar_one_or_none():
skipped += 1
continue
# Используем умеренный семафор, чтобы не превышать лимиты подключений к БД
semaphore = asyncio.Semaphore(30) # Баланс между производительностью и нагрузкой на БД
response = PollResponse(
poll_id=poll_id,
user_id=user.id,
)
db.add(response)
# Создаем отдельную функцию для создания отдельной сессии для каждой отправки
async def send_poll_invitation(user_snapshot):
"""Отправляет приглашение к опросу одному пользователю"""
async with semaphore:
# Пропускаем пользователей, которые уже прошли опрос
if user_snapshot.id in existing_user_ids:
return "skipped"
# Создаем новую сессию для изоляции транзакции
async with AsyncSessionLocal() as new_db:
try:
# Проверяем еще раз в новой сессии на случай гонки
existing_response = await new_db.execute(
select(PollResponse.id).where(
and_(
PollResponse.poll_id == poll_id,
PollResponse.user_id == user_snapshot.id,
)
)
)
existing_id = existing_response.scalar_one_or_none()
if existing_id:
return "skipped"
try:
await db.flush()
response = PollResponse(
poll_id=poll_id,
user_id=user_snapshot.id,
)
new_db.add(response)
text = _build_poll_invitation_text(poll_snapshot, user.language)
keyboard = build_start_keyboard(response.id, user.language)
await new_db.flush()
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML",
disable_web_page_preview=True,
)
text = _build_poll_invitation_text(poll, user_snapshot.language)
keyboard = build_start_keyboard(response.id, user_snapshot.language)
await db.commit()
sent += 1
await bot.send_message(
chat_id=user_snapshot.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML",
disable_web_page_preview=True,
)
await new_db.commit()
return "sent"
except TelegramBadRequest as error:
error_text = str(error).lower()
if "chat not found" in error_text or "bot was blocked by the user" in error_text:
await new_db.rollback()
return "skipped"
else: # pragma: no cover - unexpected telegram error
await new_db.rollback()
return "failed"
except Exception as error: # pragma: no cover - defensive logging
await new_db.rollback()
# Проверяем, является ли ошибка связанной с лимитом подключений
if "too many clients" in str(error).lower():
logger.warning(
"⚠️ Ограничение на количество подключений к БД: %s пользователю %s",
poll_id,
user_snapshot.telegram_id,
)
# Уменьшаем вероятность переполнения, делая небольшую задержку
await asyncio.sleep(0.1)
else:
logger.error(
"❌ Ошибка отправки опроса %s пользователю %s: %s",
poll_id,
user_snapshot.telegram_id,
error,
)
return "failed"
# Отправляем все приглашения одновременно без задержек для максимальной скорости
tasks = [send_poll_invitation(user_snapshot) for user_snapshot in user_snapshots]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, str): # Успешно выполненная задача
if result == "sent":
sent += 1
elif result == "failed":
failed += 1
elif result == "skipped":
if index % 20 == 0:
await asyncio.sleep(1)
except TelegramBadRequest as error:
error_text = str(error).lower()
if "chat not found" in error_text or "bot was blocked by the user" in error_text:
skipped += 1
elif isinstance(result, Exception): # Ошибка выполнения задачи
logger.info(
"ℹ️ Пропуск пользователя %s при отправке опроса %s: %s",
user.telegram_id,
poll_id,
error,
)
else: # pragma: no cover - unexpected telegram error
failed += 1
logger.error(
"❌ Ошибка отправки опроса %s пользователю %s: %s",
poll_id,
user.telegram_id,
error,
)
await db.rollback()
except Exception as error: # pragma: no cover - defensive logging
failed += 1
logger.error(
"❌ Ошибка отправки опроса %s пользователю %s: %s",
poll_id,
user.telegram_id,
error,
)
await db.rollback()
return {
"sent": sent,
+16 -20
View File
@@ -92,19 +92,18 @@ async def maybe_assign_promo_group_by_total_spent(
db: AsyncSession,
user_id: int,
) -> Optional[PromoGroup]:
from app.database.crud.user_promo_group import (
add_user_to_promo_group,
has_user_promo_group,
sync_user_primary_promo_group,
)
user = await db.get(User, user_id)
if not user:
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
return None
# Получаем текущую primary промогруппу
old_group = user.get_primary_promo_group()
old_group = None
if user.promo_group_id:
try:
await db.refresh(user, attribute_names=["promo_group"])
except Exception:
pass
old_group = getattr(user, "promo_group", None)
total_spent = await get_user_total_spent_kopeks(db, user_id)
if total_spent <= 0:
@@ -121,6 +120,7 @@ async def maybe_assign_promo_group_by_total_spent(
return None
try:
previous_group_id = user.promo_group_id
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
if target_threshold <= previous_threshold:
@@ -133,16 +133,12 @@ async def maybe_assign_promo_group_by_total_spent(
)
return None
# Проверяем, есть ли уже эта группа у пользователя
already_has_group = await has_user_promo_group(db, user_id, target_group.id)
if user.auto_promo_group_assigned and already_has_group:
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
logger.debug(
"Пользователь %s уже имеет промогруппу '%s', повторная выдача не требуется",
"Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется",
user.telegram_id,
target_group.name,
)
await sync_user_primary_promo_group(db, user_id)
if target_threshold > previous_threshold:
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.utcnow()
@@ -154,18 +150,18 @@ async def maybe_assign_promo_group_by_total_spent(
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.utcnow()
if not already_has_group:
# Добавляем новую промогруппу к существующим
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by="auto")
if target_group.id != previous_group_id:
user.promo_group_id = target_group.id
user.promo_group = target_group
logger.info(
"🤖 Пользователю %s добавлена промогруппа '%s' за траты %s",
"🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s",
user.telegram_id,
target_group.name,
total_spent / 100,
)
else:
logger.info(
"🤖 Пользователь %s уже имеет промогруппу '%s', отмечаем автоприсвоение",
"🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение",
user.telegram_id,
target_group.name,
)
@@ -173,7 +169,7 @@ async def maybe_assign_promo_group_by_total_spent(
await db.commit()
await db.refresh(user)
if not already_has_group:
if target_group.id != previous_group_id:
await _notify_admins_about_auto_assignment(
db,
user,
+8 -62
View File
@@ -9,10 +9,6 @@ from app.database.crud.promocode import (
)
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
from app.database.crud.user_promo_group import (
has_user_promo_group, add_user_to_promo_group
)
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
@@ -60,47 +56,6 @@ class PromoCodeService:
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
# Assign promo group if promocode has one
if promocode.promo_group_id:
try:
# Check if user already has this promo group
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
if not has_group:
# Get promo group details
promo_group = await get_promo_group_by_id(db, promocode.promo_group_id)
if promo_group:
# Add promo group to user
await add_user_to_promo_group(
db,
user_id,
promocode.promo_group_id,
assigned_by="promocode"
)
logger.info(
f"🎯 Пользователю {user.telegram_id} назначена промогруппа '{promo_group.name}' "
f"(приоритет: {promo_group.priority}) через промокод {code}"
)
# Add to result description
result_description += f"\n🎁 Назначена промогруппа: {promo_group.name}"
else:
logger.warning(
f"⚠️ Промогруппа ID {promocode.promo_group_id} не найдена для промокода {code}"
)
else:
logger.info(
f"️ Пользователь {user.telegram_id} уже имеет промогруппу ID {promocode.promo_group_id}"
)
except Exception as pg_error:
logger.error(
f"❌ Ошибка назначения промогруппы для пользователя {user.telegram_id} "
f"при активации промокода {code}: {pg_error}"
)
# Don't fail the whole promocode activation if promo group assignment fails
await create_promocode_use(db, promocode.id, user_id)
promocode.current_uses += 1
@@ -116,7 +71,6 @@ class PromoCodeService:
"max_uses": promocode.max_uses,
"current_uses": promocode.current_uses,
"valid_until": promocode.valid_until,
"promo_group_id": promocode.promo_group_id,
}
return {
@@ -162,7 +116,10 @@ class PromoCodeService:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(db)
trial_uuid = await get_random_trial_squad_uuid(
db,
settings.TRIAL_SQUAD_UUID,
)
if trial_uuid:
trial_squads = [trial_uuid]
except Exception as error:
@@ -171,21 +128,15 @@ class PromoCodeService:
promocode.code,
error,
)
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
device_limit = settings.DEFAULT_DEVICE_LIMIT
if forced_devices is not None:
device_limit = forced_devices
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=promocode.subscription_days,
traffic_limit_gb=0,
device_limit=device_limit,
device_limit=1,
connected_squads=trial_squads,
update_server_counters=True,
)
@@ -204,15 +155,10 @@ class PromoCodeService:
if not subscription:
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
trial_subscription = await create_trial_subscription(
db,
user.id,
duration_days=trial_days,
device_limit=forced_devices,
duration_days=trial_days
)
await self.subscription_service.create_remnawave_user(db, trial_subscription)
+34 -79
View File
@@ -89,64 +89,16 @@ async def process_referral_topup(
logger.info(f"Пользователь {user_id} не является рефералом")
return True
if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума")
return True
referrer = await get_user_by_id(db, user.referred_by_id)
if not referrer:
logger.error(f"Реферер {user.referred_by_id} не найден")
return False
qualifies_for_first_bonus = (
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
)
commission_amount = 0
if settings.REFERRAL_COMMISSION_PERCENT > 0:
commission_amount = int(
topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
)
if not user.has_made_first_topup:
if not qualifies_for_first_bonus:
logger.info(
"Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена",
user_id,
topup_amount_kopeks / 100,
)
if commission_amount > 0:
await add_user_balance(
db,
referrer,
commission_amount,
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
bot=bot,
)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user.id,
amount_kopeks=commission_amount,
reason="referral_commission_topup",
)
logger.info(
"💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)",
referrer.telegram_id,
commission_amount / 100,
)
if bot:
commission_notification = (
f"💰 <b>Реферальная комиссия!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
f"{settings.format_price(topup_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
return True
user.has_made_first_topup = True
await db.commit()
@@ -209,33 +161,36 @@ async def process_referral_topup(
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
else:
if commission_amount > 0:
await add_user_balance(
db, referrer, commission_amount,
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
bot=bot
)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user.id,
amount_kopeks=commission_amount,
reason="referral_commission_topup"
)
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}")
if bot:
commission_notification = (
f"💰 <b>Реферальная комиссия!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
f"{settings.format_price(topup_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
if settings.REFERRAL_COMMISSION_PERCENT > 0:
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
if commission_amount > 0:
await add_user_balance(
db, referrer, commission_amount,
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
bot=bot
)
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user.id,
amount_kopeks=commission_amount,
reason="referral_commission_topup"
)
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}")
if bot:
commission_notification = (
f"💰 <b>Реферальная комиссия!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
f"{settings.format_price(topup_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
return True
File diff suppressed because it is too large Load Diff
@@ -29,7 +29,6 @@ from app.services.subscription_purchase_service import (
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.utils.pricing_utils import format_period_description
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -196,16 +195,11 @@ async def _prepare_auto_extend_context(
def _apply_extension_updates(context: AutoExtendContext) -> None:
"""
Применяет обновления лимитов подписки (трафик, устройства, серверы).
НЕ изменяет is_trial - это делается позже после успешного коммита продления.
"""
subscription = context.subscription
# Обновляем лимиты для триальной подписки
if subscription.is_trial:
# НЕ удаляем триал здесь! Это будет сделано после успешного extend_subscription()
# subscription.is_trial = False # УДАЛЕНО: преждевременное удаление триала
subscription.is_trial = False
subscription.status = "active"
if context.traffic_limit_gb is not None:
subscription.traffic_limit_gb = context.traffic_limit_gb
if context.device_limit is not None:
@@ -213,7 +207,6 @@ def _apply_extension_updates(context: AutoExtendContext) -> None:
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
else:
# Обновляем лимиты для платной подписки
if context.traffic_limit_gb not in (None, 0):
subscription.traffic_limit_gb = context.traffic_limit_gb
if (
@@ -281,7 +274,6 @@ async def _auto_extend_subscription(
subscription = prepared.subscription
old_end_date = subscription.end_date
was_trial = subscription.is_trial # Запоминаем, была ли подписка триальной
_apply_extension_updates(prepared)
@@ -291,19 +283,6 @@ async def _auto_extend_subscription(
subscription,
prepared.period_days,
)
# НОВОЕ: Конвертируем триал в платную подписку ТОЛЬКО после успешного продления
if was_trial and subscription.is_trial:
subscription.is_trial = False
subscription.status = "active"
user.has_had_paid_subscription = True
await db.commit()
logger.info(
"✅ Триал конвертирован в платную подписку %s для пользователя %s",
subscription.id,
user.telegram_id,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"❌ Автопокупка: не удалось продлить подписку пользователя %s: %s",
@@ -311,8 +290,6 @@ async def _auto_extend_subscription(
error,
exc_info=True,
)
# НОВОЕ: Откатываем изменения при ошибке
await db.rollback()
return False
transaction = None
@@ -356,7 +333,7 @@ async def _auto_extend_subscription(
getattr(user, "language", "ru"),
)
new_end_date = updated_subscription.end_date
end_date_label = format_local_datetime(new_end_date, "%d.%m.%Y %H:%M")
end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M")
if bot:
try:
+18 -62
View File
@@ -17,9 +17,6 @@ from app.utils.pricing_utils import (
calculate_prorated_price,
validate_pricing_calculation
)
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
logger = logging.getLogger(__name__)
@@ -50,7 +47,7 @@ def _resolve_addon_discount_percent(
*,
period_days: Optional[int] = None,
) -> int:
group = promo_group or (user.get_primary_promo_group() if user else None)
group = promo_group or (getattr(user, "promo_group", None) if user else None)
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
return 0
@@ -175,7 +172,6 @@ class SubscriptionService:
return None
async with self.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
@@ -187,24 +183,20 @@ class SubscriptionService:
except Exception as hwid_error:
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
update_kwargs = dict(
updated_user = await api.update_user(
uuid=remnawave_user.uuid,
status=UserStatus.ACTIVE,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=subscription.connected_squads
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
updated_user = await api.update_user(**update_kwargs)
if reset_traffic:
await self._reset_user_traffic(
@@ -216,31 +208,23 @@ class SubscriptionService:
else:
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
username = settings.format_remnawave_username(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
)
create_kwargs = dict(
username = f"user_{user.telegram_id}"
updated_user = await api.create_user(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(),
telegram_id=user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=subscription.connected_squads
)
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
updated_user = await api.create_user(**create_kwargs)
if reset_traffic:
await self._reset_user_traffic(
api,
@@ -298,26 +282,20 @@ class SubscriptionService:
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
async with self.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
update_kwargs = dict(
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=subscription.connected_squads
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
updated_user = await api.update_user(**update_kwargs)
if reset_traffic:
await self._reset_user_traffic(
@@ -470,7 +448,7 @@ class SubscriptionService:
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
promo_group = promo_group or (user.promo_group if user else None)
traffic_price = settings.get_traffic_price(traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
@@ -570,7 +548,7 @@ class SubscriptionService:
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
promo_group = promo_group or (user.promo_group if user else None)
servers_price, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads,
@@ -587,18 +565,7 @@ class SubscriptionService:
servers_discount = servers_price * servers_discount_percent // 100
discounted_servers_price = servers_price - servers_discount
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
@@ -650,7 +617,7 @@ class SubscriptionService:
)
logger.info(message)
if devices_price > 0:
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}"
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
@@ -798,7 +765,7 @@ class SubscriptionService:
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
promo_group = promo_group or (user.promo_group if user else None)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
@@ -910,7 +877,7 @@ class SubscriptionService:
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
promo_group = promo_group or (user.promo_group if user else None)
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads,
@@ -927,18 +894,7 @@ class SubscriptionService:
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_price = discounted_servers_per_month * months_in_period
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
+6 -114
View File
@@ -9,13 +9,7 @@ from app.database.universal_migration import ensure_default_web_api_token
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import (
Settings,
settings,
refresh_period_prices,
refresh_traffic_prices,
ENV_OVERRIDE_KEYS,
)
from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices
from app.database.crud.system_setting import (
delete_system_setting,
upsert_system_setting,
@@ -77,14 +71,12 @@ class BotConfigurationService:
"SUPPORT": "💬 Поддержка и тикеты",
"LOCALIZATION": "🌍 Языки интерфейса",
"CHANNEL": "📣 Обязательная подписка",
"TIMEZONE": "🗂 Timezone",
"PAYMENT": "💳 Общие платежные настройки",
"PAYMENT_VERIFICATION": "🕵️ Проверка платежей",
"TELEGRAM": "⭐ Telegram Stars",
"CRYPTOBOT": "🪙 CryptoBot",
"HELEKET": "🪙 Heleket",
"YOOKASSA": "🟣 YooKassa",
"PLATEGA": "💳 Platega",
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 {mulenpay_name}",
"PAL24": "🏦 PAL24 / PayPalych",
@@ -132,13 +124,11 @@ class BotConfigurationService:
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
"TIMEZONE": "Часовой пояс панели и отображение времени.",
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
"PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.",
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
"PLATEGA": "Platega: merchant ID, секрет, ссылки возврата и методы оплаты.",
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
"TRIBUTE": "Tribute и донат-сервисы.",
@@ -205,8 +195,6 @@ class BotConfigurationService:
"DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE",
"MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE",
"PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE",
"DEVICES_SELECTION_ENABLED": "SUBSCRIPTIONS_CORE",
"DEVICES_SELECTION_DISABLED_AMOUNT": "SUBSCRIPTIONS_CORE",
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
@@ -223,6 +211,7 @@ class BotConfigurationService:
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
"REFERRED_USER_REWARD": "REFERRAL",
"DEFAULT_AUTOPAY_ENABLED": "AUTOPAY",
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
@@ -272,7 +261,6 @@ class BotConfigurationService:
"MAINTENANCE_MESSAGE": "MAINTENANCE",
"MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE",
"MAINTENANCE_AUTO_ENABLE": "MAINTENANCE",
"MAINTENANCE_RETRY_ATTEMPTS": "MAINTENANCE",
"WEBHOOK_URL": "WEBHOOK",
"WEBHOOK_SECRET": "WEBHOOK",
"VERSION_CHECK_ENABLED": "VERSION",
@@ -280,7 +268,6 @@ class BotConfigurationService:
"VERSION_CHECK_INTERVAL_HOURS": "VERSION",
"TELEGRAM_STARS_RATE_RUB": "TELEGRAM",
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE",
"REMNAWAVE_USER_USERNAME_TEMPLATE": "REMNAWAVE",
"REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE",
"REMNAWAVE_AUTO_SYNC_TIMES": "REMNAWAVE",
}
@@ -305,7 +292,6 @@ class BotConfigurationService:
"YOOKASSA_": "YOOKASSA",
"CRYPTOBOT_": "CRYPTOBOT",
"HELEKET_": "HELEKET",
"PLATEGA_": "PLATEGA",
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
@@ -464,21 +450,6 @@ class BotConfigurationService:
"example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba",
"warning": "Убедитесь, что выбранный сквад активен и доступен для подписки.",
},
"DEVICES_SELECTION_ENABLED": {
"description": "Разрешает пользователям выбирать количество устройств при покупке и продлении подписки.",
"format": "Булево значение.",
"example": "false",
"warning": "При отключении пользователи не смогут докупать устройства из интерфейса бота.",
},
"DEVICES_SELECTION_DISABLED_AMOUNT": {
"description": (
"Лимит устройств, который автоматически назначается, когда выбор количества устройств выключен. "
"Значение 0 отключает назначение устройств."
),
"format": "Целое число от 0 и выше.",
"example": "3",
"warning": "При 0 RemnaWave не получит лимит устройств, пользователям не показываются цифры в интерфейсе.",
},
"CRYPTOBOT_ENABLED": {
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
"format": "Булево значение.",
@@ -528,28 +499,6 @@ class BotConfigurationService:
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
},
"MAINTENANCE_MONITORING_ENABLED": {
"description": (
"Управляет автоматическим запуском мониторинга панели Remnawave при старте бота."
),
"format": "Булево значение.",
"example": "false",
"warning": (
"При отключении мониторинг можно запустить вручную из панели администратора."
),
"dependencies": "MAINTENANCE_CHECK_INTERVAL",
},
"MAINTENANCE_RETRY_ATTEMPTS": {
"description": (
"Сколько раз повторять проверку панели Remnawave перед фиксацией недоступности."
),
"format": "Целое число не меньше 1.",
"example": "3",
"warning": (
"Большие значения увеличивают время реакции на реальные сбои, но помогают избежать ложных срабатываний."
),
"dependencies": "MAINTENANCE_CHECK_INTERVAL",
},
"DISPLAY_NAME_BANNED_KEYWORDS": {
"description": (
"Список слов и фрагментов, при наличии которых в отображаемом имени "
@@ -587,31 +536,6 @@ class BotConfigurationService:
),
"dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED",
},
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": {
"description": (
"Шаблон текста, который бот передает в поле Description при создании "
"или обновлении пользователя в панели RemnaWave."
),
"format": (
"Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}."
),
"example": "Bot user: {full_name} {username}",
"warning": "Плейсхолдер {username} автоматически очищается, если у пользователя нет @username.",
},
"REMNAWAVE_USER_USERNAME_TEMPLATE": {
"description": (
"Шаблон имени пользователя, которое создаётся в панели RemnaWave для "
"телеграм-пользователя."
),
"format": (
"Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}."
),
"example": "vpn_{username_clean}_{telegram_id}",
"warning": (
"Недопустимые символы автоматически заменяются на подчёркивания. "
"Если результат пустой, используется user_{telegram_id}."
),
},
"EXTERNAL_ADMIN_TOKEN": {
"description": "Приватный токен, который использует внешняя админка для проверки запросов.",
"format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",
@@ -642,10 +566,6 @@ class BotConfigurationService:
def is_read_only(cls, key: str) -> bool:
return key in cls.READ_ONLY_KEYS
@classmethod
def _is_env_override(cls, key: str) -> bool:
return key in cls._env_override_keys
@classmethod
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
if isinstance(value, bool):
@@ -759,7 +679,6 @@ class BotConfigurationService:
_definitions: Dict[str, SettingDefinition] = {}
_original_values: Dict[str, Any] = settings.model_dump()
_overrides_raw: Dict[str, Optional[str]] = {}
_env_override_keys: set[str] = set(ENV_OVERRIDE_KEYS)
_callback_tokens: Dict[str, str] = {}
_token_to_key: Dict[str, str] = {}
_choice_tokens: Dict[str, Dict[Any, str]] = {}
@@ -883,8 +802,6 @@ class BotConfigurationService:
@classmethod
def has_override(cls, key: str) -> bool:
if cls._is_env_override(key):
return False
return key in cls._overrides_raw
@classmethod
@@ -1180,12 +1097,6 @@ class BotConfigurationService:
overrides[row.key] = row.value
for key, raw_value in overrides.items():
if cls._is_env_override(key):
logger.debug(
"Пропускаем настройку %s из БД: используется значение из окружения",
key,
)
continue
try:
parsed_value = cls.deserialize_value(key, raw_value)
except Exception as error:
@@ -1305,15 +1216,8 @@ class BotConfigurationService:
raw_value = cls.serialize_value(key, value)
await upsert_system_setting(db, key, raw_value)
if cls._is_env_override(key):
logger.info(
"Настройка %s сохранена в БД, но не применена: значение задаётся через окружение",
key,
)
cls._overrides_raw.pop(key, None)
else:
cls._overrides_raw[key] = raw_value
cls._apply_to_settings(key, value)
cls._overrides_raw[key] = raw_value
cls._apply_to_settings(key, value)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@@ -1331,26 +1235,14 @@ class BotConfigurationService:
await delete_system_setting(db, key)
cls._overrides_raw.pop(key, None)
if cls._is_env_override(key):
logger.info(
"Настройка %s сброшена в БД, используется значение из окружения",
key,
)
else:
original = cls.get_original_value(key)
cls._apply_to_settings(key, original)
original = cls.get_original_value(key)
cls._apply_to_settings(key, original)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@classmethod
def _apply_to_settings(cls, key: str, value: Any) -> None:
if cls._is_env_override(key):
logger.debug(
"Пропуск применения настройки %s: значение задано через окружение",
key,
)
return
try:
setattr(settings, key, value)
if key in {

Some files were not shown because too many files have changed in this diff Show More