Compare commits
279 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a61fb10784 | |||
| 8d83881cf0 | |||
| d624df5f65 | |||
| a96a7de784 | |||
| fa188e04c5 | |||
| e8c95ece8d | |||
| bc0f4adb7a | |||
| 3eb5a71e8d | |||
| 37c97de5d6 | |||
| 3624832ed8 | |||
| 9ee39d5f71 | |||
| 3a0e558b6f | |||
| ca083c216e | |||
| 8b9c1d1cc6 | |||
| 78830a7884 | |||
| 9fe7d0592f | |||
| 6a3e3c6fc4 | |||
| 54bfff0ed6 | |||
| 6ee305cbba | |||
| 340041ddae | |||
| 8a3628a6c5 | |||
| 8ac3350ac6 | |||
| a507ef9966 | |||
| 113405ed71 | |||
| 1bdf1a0f4c | |||
| ff7883e33c | |||
| 9bd909c1b6 | |||
| 408b4e8a4d | |||
| 0ee9708b4d | |||
| 44fadf6709 | |||
| 7384763466 | |||
| 9b6f3b10c6 | |||
| 0e4007bde6 | |||
| 9fcffc7df1 | |||
| 8e1a88c5cd | |||
| 7bb21c9f77 | |||
| 7333379fcf | |||
| fc040007b4 | |||
| b30a3c7961 | |||
| 62e18156f5 | |||
| 853666af6f | |||
| 3416f7b56c | |||
| d78d82c6ce | |||
| 47bd1be714 | |||
| 1eec919b88 | |||
| 777ad52bad | |||
| 5fcadb5544 | |||
| 0a8af20b58 | |||
| 95163a0e35 | |||
| ac58466c1a | |||
| 5ce14dcd9b | |||
| 21d700e16d | |||
| 5f2d4a5e04 | |||
| 7b90d73007 | |||
| 68c90aec44 | |||
| 9280c9a39a | |||
| 245e31e262 | |||
| c00d7ef4d3 | |||
| 2a652c562b | |||
| 1cc31729eb | |||
| 34e5c20c1a | |||
| c8545fe725 | |||
| a8339d376d | |||
| 47150ccbd0 | |||
| 0a0890d8bb | |||
| 6bcf24e3d8 | |||
| 898008a1bd | |||
| 09af2e1d41 | |||
| 045d44f451 | |||
| 04b2325cb9 | |||
| 504581b0f8 | |||
| 0572f414d4 | |||
| 50736dc005 | |||
| fe4bdb6a04 | |||
| 7d7c5f3972 | |||
| 131a2d18cc | |||
| 47296c5c08 | |||
| c15b7a63fb | |||
| e476afe208 | |||
| b59d422fb0 | |||
| 8b105c2396 | |||
| 9b6e0b26c3 | |||
| 4ca038e186 | |||
| 3e185e7ffe | |||
| 5e27dcdcfb | |||
| 8730a68b40 | |||
| d88f13784a | |||
| 6e471c9094 | |||
| 3201b60ff2 | |||
| d30d1e2a29 | |||
| bd73ad069f | |||
| 2186780c32 | |||
| fbb2e561c5 | |||
| c8aba42d3d | |||
| 05d495e874 | |||
| 427011fe41 | |||
| 2feacccb13 | |||
| dc196fb478 | |||
| 71c219e6ea | |||
| e250812463 | |||
| 477b0b26c4 | |||
| 06323f7168 | |||
| 6b471caa22 | |||
| 6fb28dd363 | |||
| 22dcff47d1 | |||
| 1d9b5ea3c6 | |||
| 08e0b3a657 | |||
| 10b80981b2 | |||
| 348f02f34c | |||
| 9e9e984f36 | |||
| d47ca550ae | |||
| 16f4285948 | |||
| 23cb6dd8d0 | |||
| dfbb6b0058 | |||
| 62f8fdd9bb | |||
| fc114d15a0 | |||
| a9fb8a069d | |||
| 9c6f738382 | |||
| 1a7b2eb908 | |||
| b108cc6b60 | |||
| 92cb1e3971 | |||
| 005fc95433 | |||
| f09771bc1c | |||
| d5b72198d7 | |||
| 991bf1161c | |||
| 1a955d920a | |||
| d575c914a6 | |||
| 90a054e942 | |||
| aac5fabbca | |||
| 3ce5d1650b | |||
| 24879b9cf2 | |||
| 37f27df062 | |||
| b78ed6797a | |||
| f73dcd6f66 | |||
| 0634d01336 | |||
| b43d6a83ee | |||
| 93144eea0e | |||
| 7d078a58e1 | |||
| 224c346bc0 | |||
| 51e937ae0c | |||
| 04073815ca | |||
| ff556ba347 | |||
| 2d751c238c | |||
| 5d27fa2963 | |||
| fe71935d58 | |||
| d0d1b7617a | |||
| 310990ae32 | |||
| b565c32e77 | |||
| f3edb4344b | |||
| ccdcff6a7b | |||
| 1d4090ea7a | |||
| 68c7d35165 | |||
| 221cc05af3 | |||
| 1524970401 | |||
| 32a03c5e4b | |||
| a03036c099 | |||
| 811e8a97b3 | |||
| c7625643c6 | |||
| 5ce87238c8 | |||
| 7d12a327ca | |||
| 8c93637d79 | |||
| 2d0ea02b4b | |||
| 98692ac3bd | |||
| 940b6b93b2 | |||
| 17768c303f | |||
| 441fcd2a36 | |||
| c2b6b4e370 | |||
| 2f8d27b1ec | |||
| dc32f53585 | |||
| 165691412b | |||
| 824d4b3a23 | |||
| 060f0ca199 | |||
| ac43f17a43 | |||
| 54fc1c029c | |||
| d259bcf169 | |||
| da46c18210 | |||
| ace7121a4e | |||
| 61a93f86b3 | |||
| 49bcd18a9a | |||
| 7df64d8edf | |||
| ca63b4d758 | |||
| 3cbb304e9b | |||
| ef5192234a | |||
| f7bb9ac4d9 | |||
| b8bb4f3c3f | |||
| e43ff27639 | |||
| e02123c1ed | |||
| f26df389b4 | |||
| 10d36a829d | |||
| 61e936dd49 | |||
| d0f9a04d5f | |||
| cf6bbffd9b | |||
| 5ad45addfa | |||
| 2af013b75b | |||
| 74bda5c986 | |||
| b290ccfff2 | |||
| 904d2883e8 | |||
| 1a54beed17 | |||
| 701a8d33af | |||
| aa03384012 | |||
| d346c63184 | |||
| b8005aaad7 | |||
| bfa9490425 | |||
| 1f4af21a07 | |||
| f952c0e38d | |||
| d7dab5d30f | |||
| 8407f7cdb1 | |||
| 6c72987201 | |||
| 81b6d3f18f | |||
| 95754ab224 | |||
| 949d964a7f | |||
| 8eab79434a | |||
| 8ab2351995 | |||
| e8802dde55 | |||
| acc5ace234 | |||
| 7b33721305 | |||
| e94f4598b5 | |||
| c66c8b7933 | |||
| 0b934efa09 | |||
| 79e644f6dd | |||
| 5c68a1aca7 | |||
| bde613c4bf | |||
| 184d8c3ba7 | |||
| d48221d57f | |||
| 657fd6d07d | |||
| 1211cd22ad | |||
| 19ed88009d | |||
| 0f30ee87ab | |||
| 4cc6778e45 | |||
| ab00a0d136 | |||
| 3d4f1af9a9 | |||
| 0302176fd2 | |||
| 8910780332 | |||
| 8bea64179a | |||
| 777f90eab1 | |||
| ad0b0f475a | |||
| 499292eab6 | |||
| 21b2f1bd2a | |||
| 3c025b536e | |||
| bded6fe59f | |||
| 344ad8f3c0 | |||
| b43dd3167f | |||
| 79ed9dbab2 | |||
| 38ff59f71f | |||
| 65c85e146d | |||
| 467955e7b3 | |||
| 1a4250a7ed | |||
| e181e4325b | |||
| 1a35237852 | |||
| 90baf3da52 | |||
| 348e4dd4dc | |||
| d2cd96a45c | |||
| 64238e3280 | |||
| fcfac2df1f | |||
| a0734a6eb4 | |||
| 0d7a235715 | |||
| 180788bc7b | |||
| 922681891a | |||
| 257c628cc8 | |||
| dcee842c92 | |||
| 5f02471fd5 | |||
| 82f0f7ce17 | |||
| b805b7eef9 | |||
| ad03fa070a | |||
| 9c354d971f | |||
| 5cc70c4bb8 | |||
| 9db806d387 | |||
| b7f7621d28 | |||
| 822ac2417d | |||
| b3c80a2a82 | |||
| 3e9d606647 | |||
| 0ab89f4956 | |||
| 0090f1c409 | |||
| 1cc37a57c1 | |||
| 7809efa556 | |||
| b4a69e3c85 | |||
| 67c975d9c2 | |||
| c0812a7ee9 | |||
| 8ed755a339 |
+46
-3
@@ -67,6 +67,14 @@ 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 - только деактивировать пользователя
|
||||
@@ -146,6 +154,10 @@ 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
|
||||
@@ -167,8 +179,8 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
TELEGRAM_STARS_ENABLED=false
|
||||
TELEGRAM_STARS_RATE_RUB=1.79
|
||||
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
@@ -176,6 +188,8 @@ 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)
|
||||
@@ -240,8 +254,13 @@ 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=Пополнение баланса
|
||||
@@ -256,6 +275,8 @@ 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
|
||||
@@ -332,6 +353,7 @@ 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
|
||||
@@ -383,6 +405,8 @@ 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=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
@@ -391,6 +415,8 @@ DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
# Часовой пояс
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
@@ -428,3 +454,20 @@ 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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: Bug Bedolage
|
||||
about: Создать баг-репорт «Бедолаге»
|
||||
title: "[BUG] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем текущую проблему.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые имеются):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Шаги для воспроизведения
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Фактический результат
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
Опишите, как должно работать.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Feat Bedolage
|
||||
about: Создать запрос на фичу «Бедолаге»
|
||||
title: "[FEAT] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем что хотим.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые к месту):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Текущее поведение
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Желаемое поведение
|
||||
|
||||
Опишите, как хотелось бы, чтобы было.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -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.5.6-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-$(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.5.6-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-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.5.6-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.6"
|
||||
ARG VERSION="v2.6.0"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8080 8081 8082
|
||||
EXPOSE 8080
|
||||
|
||||
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:8081/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.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 ""
|
||||
@@ -73,14 +73,6 @@
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 🧙♂️ Автоустановка через `install_bot.sh`
|
||||
|
||||
Скрипт-установщик берёт на себя подготовку окружения, настройку конфигурации и дальнейшее обслуживание бота. Он работает поверх Docker Compose и требует заранее установить:
|
||||
|
||||
- **Docker Engine** и **Docker Compose plugin** (2.20+);
|
||||
- **Git** и **Bash** (по умолчанию есть в большинстве Linux дистрибутивов);
|
||||
- `openssl` (используется для генерации токенов, но не обязателен — при отсутствии скрипт использует `urandom`).
|
||||
|
||||
```bash
|
||||
# 1. Скачай репозиторий
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
@@ -91,32 +83,18 @@ mkdir -p ./logs ./data ./data/backups ./data/referral_qr
|
||||
chmod -R 755 ./logs ./data
|
||||
sudo chown -R 1000:1000 ./logs ./data
|
||||
|
||||
# 3. Запусти мастер установки
|
||||
chmod +x install_bot.sh
|
||||
./install_bot.sh
|
||||
|
||||
# 3. make up # Поднять контейнеры
|
||||
|
||||
make help # Показать все команды
|
||||
make down # Остановить контейнеры
|
||||
make reload # Перезапустить
|
||||
make reload-follow # Перезапустить с логами
|
||||
make test # Запустить тесты
|
||||
|
||||
|
||||
```
|
||||
|
||||
На первом запуске мастер:
|
||||
|
||||
1. Спросит путь установки и сохранит его в `./.bot_install_state` — можно оставлять путь по умолчанию (текущая директория).
|
||||
2. Поможет собрать `.env`: запросит обязательные токены (бот, Remnawave, админы), при необходимости сгенерирует Web API и PostgreSQL пароли, предложит авторизацию Basic Auth или eGames secret.
|
||||
3. Подготовит структуру каталогов (`logs`, `data`, `backups` и т. д.) и проверит, что Docker готов к запуску.
|
||||
4. Создаст (или обновит) `docker-compose.yml`, настроит внешнюю сеть `bot_network`, чтобы дальнейшие сервисы (например, Caddy) могли подключаться.
|
||||
5. Запустит контейнеры бота, PostgreSQL и Redis и выведет их статус.
|
||||
|
||||
После установки повторный запуск `./install_bot.sh` открывает **интерактивное меню управления**:
|
||||
|
||||
- 📊 Мониторинг состояния контейнеров и ресурсов
|
||||
- ⚙️ Управление сервисами (запуск/остановка/пересборка)
|
||||
- 📋 Просмотр и поиск по логам
|
||||
- 🔄 Обновление проекта из Git с автоматическим бэкапом
|
||||
- 💾 Создание и 📦 восстановление резервных копий (включая базу данных)
|
||||
- 🧹 Очистка логов, бэкапов и образов
|
||||
- 🌐 Помощник настройки обратного прокси Caddy (webhook + miniapp, обновление `docker-compose`, перезагрузка)
|
||||
- ⚙️ Конфигуратор `.env` (редактирование, пересоздание, маскировка секретов)
|
||||
|
||||
> 💡 Скрипт можно запускать сколько угодно раз — он хранит путь установки и понимает, когда конфигурация уже создана. Меню работает и по SSH (достаточно TTY), а для скриптов можно передать путь установки через stdin.
|
||||
|
||||
### 🐳 Ручной Docker запуск
|
||||
|
||||
Если не хочется пользоваться мастером, можно настроить всё вручную:
|
||||
@@ -144,35 +122,67 @@ docker compose logs
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Настройка обратного прокси и доменов
|
||||
## 🌐 Настройка webhook-режима и обратного прокси
|
||||
|
||||
> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) должны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса портов наружу.
|
||||
> Встроенный FastAPI-сервер теперь обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт.
|
||||
|
||||
### 1. Планирование доменов и переменных окружения
|
||||
### ♻️ Миграция со старой схемы (несколько портов)
|
||||
|
||||
1. Добавьте в DNS по **A/AAAA-записи** для обоих доменов на IP сервера, где запущен бот.
|
||||
2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол).
|
||||
3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами:
|
||||
```env
|
||||
WEBHOOK_URL=https://hooks.example.com
|
||||
WEB_API_ENABLED=true
|
||||
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
|
||||
MINIAPP_CUSTOM_URL=https://miniapp.example.com
|
||||
```
|
||||
Если бот уже развернут в режиме polling или с отдельными контейнерами для `payments-webhook`, выполните переход на единую схему:
|
||||
|
||||
### 2. Общая Docker-сеть для бота и прокси
|
||||
1. **Обновите `.env`:** установите `BOT_RUN_MODE=webhook` (или `both` для гибридного режима), задайте `WEBHOOK_URL`, `WEBHOOK_PATH` и обязательно сгенерируйте собственный `WEBHOOK_SECRET_TOKEN` командой `openssl rand -hex 32`.
|
||||
2. **Проверьте `docker-compose.*`:** оставьте публикацию только порта `8080` у сервиса `remnawave_bot`. Все значения `*_WEBHOOK_PORT` теперь используются лишь для обратной совместимости и могут быть удалены.
|
||||
3. **Обновите прокси:** перенаправляйте *все* пути (`/webhook`, `/yookassa-webhook`, `/cryptobot-webhook`, `/miniapp/static`, `/app-config.json` и т.д.) на один upstream `remnawave_bot:8080`. Примеры Caddy/nginx ниже можно адаптировать к текущей инфраструктуре.
|
||||
4. **Перезапустите сервисы:** `docker compose up -d --force-recreate bot` и затем перезагрузите прокси. После запуска бот автоматически зарегистрирует новый webhook.
|
||||
5. **Проверьте здоровье:** `curl http://localhost:8080/health/unified` (или `/health`, если административное API отключено) и `curl http://localhost:8080/health/telegram-webhook`. Убедитесь, что в логах нет ошибок регистрации webhook.
|
||||
|
||||
`docker-compose.yml` бота создаёт сеть `bot_network`. Чтобы внешний прокси видел сервис `remnawave_bot`, нужно:
|
||||
После миграции старые контейнеры/сервисы для отдельных вебхуков можно удалить.
|
||||
|
||||
### 1. Выбор режима запуска
|
||||
|
||||
| `BOT_RUN_MODE` | Что делает | Когда использовать |
|
||||
|----------------|------------|---------------------|
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Миграция с polling на webhook или повышенная отказоустойчивость. |
|
||||
|
||||
### 2. Переменные окружения для webhook
|
||||
|
||||
```env
|
||||
BOT_RUN_MODE=webhook
|
||||
WEBHOOK_URL=https://bot.example.com
|
||||
WEBHOOK_PATH=/telegram/webhook
|
||||
WEBHOOK_SECRET_TOKEN=super-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
|
||||
|
||||
WEB_API_ENABLED=true
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8080
|
||||
WEB_API_ALLOWED_ORIGINS=https://bot.example.com
|
||||
MINIAPP_CUSTOM_URL=https://bot.example.com/miniapp
|
||||
```
|
||||
|
||||
* `WEBHOOK_URL` — публичный HTTPS-домен прокси. К нему автоматически добавится путь из `WEBHOOK_PATH`.
|
||||
* `WEBHOOK_SECRET_TOKEN` — защитный токен Telegram, обязательно задайте своё значение.
|
||||
* Очередь можно тюнить через `WEBHOOK_MAX_QUEUE_SIZE`, `WEBHOOK_WORKERS`, `WEBHOOK_ENQUEUE_TIMEOUT` и `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT`.
|
||||
* Если миниапка или админка доступны по другим доменам, перечислите их через запятую в `WEB_API_ALLOWED_ORIGINS`.
|
||||
|
||||
После изменения `.env` перезапустите сервис: `docker compose up -d remnawave_bot`.
|
||||
|
||||
### 3. Подготовка Docker-сети
|
||||
|
||||
`docker-compose.yml` создаёт сеть `bot_network`. Прокси должен находиться в той же сети, чтобы обращаться к контейнеру по имени `remnawave_bot`.
|
||||
|
||||
```bash
|
||||
# Убедиться, что сеть существует
|
||||
docker network ls | grep bot_network || docker network create bot_network
|
||||
|
||||
# Подключить прокси (если контейнер уже запущен отдельно)
|
||||
docker network connect bot_network <proxy_container_name>
|
||||
```
|
||||
|
||||
Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю:
|
||||
Если прокси стартует отдельным compose-файлом, объявите сеть внешней:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
@@ -180,179 +190,240 @@ networks:
|
||||
external: true
|
||||
```
|
||||
|
||||
### 3. Ручная установка Caddy в Docker
|
||||
### 4. Проверка здоровья
|
||||
|
||||
1. Создайте каталог для конфигурации:
|
||||
```bash
|
||||
mkdir -p ~/caddy
|
||||
cd ~/caddy
|
||||
```
|
||||
Статические файлы миниапки автоматически монтируются из каталога `MINIAPP_STATIC_PATH` (по умолчанию `miniapp/`) и доступны по пути `/miniapp/static`.
|
||||
|
||||
2. Сохраните docker-compose-файл `docker-compose.caddy.yml`:
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: remnawave_caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
- /root/remnawave-bedolaga-telegram-bot/miniapp:/miniapp:ro
|
||||
- /root/remnawave-bedolaga-telegram-bot/miniapp/redirect:/miniapp/redirect:ro
|
||||
networks:
|
||||
- bot_network
|
||||
Проверьте, что единый сервер отвечает:
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
```bash
|
||||
curl -s https://bot.example.com/health/unified | jq
|
||||
```
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
Полезные диагностические endpoints:
|
||||
|
||||
3. Создайте `Caddyfile` с двумя виртуальными хостами:
|
||||
```caddy
|
||||
webhook.domain.com {
|
||||
handle /tribute-webhook* {
|
||||
reverse_proxy remnawave_bot:8081
|
||||
}
|
||||
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy remnawave_bot:8081
|
||||
}
|
||||
|
||||
handle /mulenpay-webhook* {
|
||||
reverse_proxy remnawave_bot:8081
|
||||
}
|
||||
|
||||
handle /pal24-webhook* {
|
||||
reverse_proxy remnawave_bot:8084
|
||||
}
|
||||
|
||||
handle /wata-webhook* {
|
||||
reverse_proxy remnawave_bot:8081
|
||||
}
|
||||
|
||||
handle /yookassa-webhook* {
|
||||
reverse_proxy remnawave_bot:8082
|
||||
}
|
||||
|
||||
handle /health {
|
||||
reverse_proxy remnawave_bot:8081/health
|
||||
}
|
||||
}
|
||||
|
||||
miniapp.domain.com {
|
||||
encode gzip zstd
|
||||
root * /miniapp
|
||||
file_server
|
||||
|
||||
@config path /app-config.json
|
||||
header @config Access-Control-Allow-Origin "*"
|
||||
|
||||
reverse_proxy /miniapp/* remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
- `/health/unified` — агрегированный статус (режим бота, очередь Telegram, наличие миниапки и платежей). Когда административное API отключено, тот же статус доступен по `/health`.
|
||||
- `/health/telegram-webhook` — состояние очереди Telegram webhook.
|
||||
- `/health/payment-webhooks` — какие платёжные интеграции активированы.
|
||||
|
||||
4. Запустите прокси:
|
||||
```bash
|
||||
docker compose -f docker-compose.caddy.yml up -d
|
||||
```
|
||||
### 5. Swagger и документация
|
||||
|
||||
### 4. Ручная настройка nginx в Docker
|
||||
- Включите `WEB_API_DOCS_ENABLED=true`, если нужно открыть Swagger UI и OpenAPI. После перезапуска сервиса станут доступны эндпоинты `/docs`, `/doc` (редирект для обратной совместимости), `/redoc` и `/openapi.json`.
|
||||
- Не забудьте проксировать эти пути через внешний HTTPS-прокси вместе с остальными эндпоинтами бота.
|
||||
- В продакшене держите `WEB_API_DOCS_ENABLED=false`, чтобы документация не была публичной. При необходимости включайте временно или защищайте прокси базовой авторизацией/IP-фильтрацией.
|
||||
|
||||
1. Создайте каталог `/opt/nginx-remnawave` и поместите туда `docker-compose.nginx.yml`:
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: remnawave_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certs:/etc/ssl/private:ro
|
||||
- ./miniapp:/var/www/remnawave-miniapp:ro
|
||||
networks:
|
||||
- bot_network
|
||||
### 6. Пример Caddy-конфига
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: remnawave_caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
2. Пример `nginx.conf`:
|
||||
```nginx
|
||||
events {}
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
upstream remnawave_bot_hooks {
|
||||
server remnawave_bot:8081;
|
||||
}
|
||||
`Caddyfile`:
|
||||
|
||||
upstream remnawave_bot_yookassa {
|
||||
server remnawave_bot:8082;
|
||||
}
|
||||
```caddy
|
||||
# API
|
||||
api.domain.com {
|
||||
encode gzip zstd
|
||||
|
||||
@config path /app-config.json
|
||||
header @config Access-Control-Allow-Origin "*"
|
||||
|
||||
reverse_proxy localhost:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upstream remnawave_bot_api {
|
||||
server remnawave_bot:8080;
|
||||
}
|
||||
# Статика для miniapp
|
||||
podpiska.domain.com {
|
||||
encode gzip zstd
|
||||
|
||||
# API эндпоинты /miniapp/* в приложение
|
||||
handle /miniapp/* {
|
||||
reverse_proxy localhost:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
handle /app-config.json {
|
||||
header Access-Control-Allow-Origin "*"
|
||||
reverse_proxy localhost:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Статические файлы (корень и всё остальное)
|
||||
handle {
|
||||
root * /var/www/remnawave-miniapp
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name hooks.example.com;
|
||||
####
|
||||
|
||||
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
|
||||
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
|
||||
|
||||
location = /webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /tribute-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /cryptobot-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /mulenpay-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /wata-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /pal24-webhook { proxy_pass http://remnawave_bot:8084; }
|
||||
location /yookassa-webhook { proxy_pass http://remnawave_bot_yookassa; }
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
### 6. Пример nginx-конфига
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name miniapp.example.com;
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: remnawave_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
root /var/www/remnawave-miniapp;
|
||||
index index.html;
|
||||
`nginx.conf`:
|
||||
|
||||
location /miniapp/ {
|
||||
proxy_pass http://remnawave_bot_api/miniapp/;
|
||||
proxy_set_header X-API-Key "КЛЮЧ-WEBAPI";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
```nginx
|
||||
events {}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
sendfile on;
|
||||
|
||||
upstream remnawave_bot_unified {
|
||||
server remnawave_bot:8080;
|
||||
}
|
||||
|
||||
# API домен
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name api.domain.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/api.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/api.privkey.pem;
|
||||
|
||||
client_max_body_size 32m;
|
||||
|
||||
# app-config.json с CORS
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Все остальное
|
||||
location / {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
# Miniapp домен (статика + API)
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name podpiska.domain.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/podpiska.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/podpiska.privkey.pem;
|
||||
|
||||
client_max_body_size 32m;
|
||||
|
||||
# API эндпоинты /miniapp/*
|
||||
location /miniapp/ {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Статические файлы
|
||||
location / {
|
||||
root /var/www/remnawave-miniapp;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Кэширование
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
####
|
||||
|
||||
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
|
||||
|
||||
Рекомендации:
|
||||
|
||||
- Откройте входящие 80/443 в фаерволе.
|
||||
- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`.
|
||||
- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook.
|
||||
|
||||
---
|
||||
|
||||
@@ -363,19 +434,31 @@ networks:
|
||||
| Настройка | Где взять | Пример |
|
||||
|-----------|-----------|---------|
|
||||
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
|
||||
| 🔑 **REMNAWAVE_API_KEY** | Твоя Remnawave панель | `eyJhbGciOiJIUzI1...` |
|
||||
| 🌐 **REMNAWAVE_API_URL** | URL твоей панели | `https://panel.example.com` |
|
||||
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
|
||||
[Полный список доступных параметров:](remnawave-bedolaga-telegram-bot/blob/main/.env.example)
|
||||
|
||||
### 🌐 Интеграция веб-админки
|
||||
|
||||
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
|
||||
|
||||
### 🤖 Режимы запуска бота
|
||||
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защищаться от перегрузок.
|
||||
- `WEBHOOK_WORKERS` — количество фоновых воркеров, параллельно обрабатывающих обновления Telegram.
|
||||
- `WEBHOOK_ENQUEUE_TIMEOUT` — сколько секунд ждать свободного места в очереди перед отказом (0 — немедленный отказ).
|
||||
- `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT` — таймаут корректного завершения воркеров при остановке приложения.
|
||||
|
||||
### 📱 Telegram Mini App ЛК
|
||||
|
||||
Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md).
|
||||
|
||||
Путь к статическим файлам мини-приложения можно переопределить через переменную `MINIAPP_STATIC_PATH`.
|
||||
|
||||
### 📊 Статус серверов в главном меню
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
@@ -471,7 +554,9 @@ REMNAWAVE_AUTO_SYNC_TIMES=03:00,15:00
|
||||
# Автоматический режим тех. работ
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
|
||||
# Интервал проверки состояния панели (секунды)
|
||||
MONITORING_INTERVAL=60
|
||||
@@ -484,204 +569,6 @@ MONITORING_INTERVAL=60
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔧 Полная конфигурация .env</summary>
|
||||
|
||||
```env
|
||||
# ===============================================
|
||||
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
|
||||
# ===============================================
|
||||
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126
|
||||
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_SEND_TIME=10:00
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID=
|
||||
CHANNEL_IS_REQUIRED_SUB=false
|
||||
CHANNEL_LINK=
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
DATABASE_MODE=auto
|
||||
DATABASE_URL=
|
||||
|
||||
# PostgreSQL настройки
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=remnawave_bot
|
||||
POSTGRES_USER=remnawave_user
|
||||
POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=your_api_key_here
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_SECRET_KEY=
|
||||
|
||||
# Автосинхронизация
|
||||
REMNAWAVE_AUTO_SYNC_ENABLED=true
|
||||
REMNAWAVE_AUTO_SYNC_TIMES=03:00,15:00
|
||||
|
||||
# Шаблон описания пользователя
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
REMNAWAVE_USER_DELETE_MODE=delete
|
||||
|
||||
# ===== ПОДПИСКИ =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
TRAFFIC_SELECTION_MODE=selectable
|
||||
FIXED_TRAFFIC_LIMIT_GB=100
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== ЦЕНЫ (в копейках) =====
|
||||
BASE_SUBSCRIPTION_PRICE=0
|
||||
PRICE_14_DAYS=7000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_60_DAYS=25900
|
||||
PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидки для базовых пользователей
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,0:20000:true"
|
||||
PRICE_PER_DEVICE=5000
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_ENABLED=true
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# Telegram Stars
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
|
||||
# Tribute
|
||||
TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
|
||||
# YooKassa
|
||||
YOOKASSA_ENABLED=false
|
||||
YOOKASSA_SHOP_ID=
|
||||
YOOKASSA_SECRET_KEY=
|
||||
YOOKASSA_SBP_ENABLED=false
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
|
||||
# CryptoBot
|
||||
CRYPTOBOT_ENABLED=false
|
||||
CRYPTOBOT_API_TOKEN=
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
|
||||
# Heleket
|
||||
HELEKET_ENABLED=false
|
||||
HELEKET_MERCHANT_ID=
|
||||
HELEKET_API_KEY=
|
||||
HELEKET_WEBHOOK_PATH=/heleket-webhook
|
||||
HELEKET_WEBHOOK_PORT=8086
|
||||
|
||||
# MulenPay
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
MULENPAY_SECRET_KEY=
|
||||
MULENPAY_SHOP_ID=
|
||||
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
|
||||
|
||||
# PayPalych / Pal24
|
||||
PAL24_ENABLED=false
|
||||
PAL24_API_TOKEN=
|
||||
PAL24_SHOP_ID=
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_SBP_BUTTON_VISIBLE=true
|
||||
PAL24_CARD_BUTTON_VISIBLE=true
|
||||
|
||||
# WATA
|
||||
WATA_ENABLED=false
|
||||
WATA_TOKEN=
|
||||
WATA_TERMINAL_ID=
|
||||
WATA_WEBHOOK_PATH=/wata-webhook
|
||||
WATA_WEBHOOK_HOST=0.0.0.0
|
||||
WATA_WEBHOOK_PORT=8085
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
MAIN_MENU_MODE=default
|
||||
HIDE_SUBSCRIPTION_LINK=false
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
MONITORING_INTERVAL=60
|
||||
ENABLE_NOTIFICATIONS=true
|
||||
NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== СТАТУС СЕРВЕРОВ =====
|
||||
SERVER_STATUS_MODE=disabled
|
||||
SERVER_STATUS_EXTERNAL_URL=
|
||||
SERVER_STATUS_METRICS_URL=
|
||||
SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# ===== СИСТЕМА БЕКАПОВ =====
|
||||
BACKUP_AUTO_ENABLED=true
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_TIME=03:00
|
||||
BACKUP_MAX_KEEP=7
|
||||
BACKUP_SEND_ENABLED=true
|
||||
|
||||
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
#### ⭐ Функционал
|
||||
@@ -712,6 +599,7 @@ LOG_FILE=logs/bot.log
|
||||
- 🔔 Уведомления об истечении и автоконверсия
|
||||
- 💎 Автовыдача бонусов за кампании и инвайты
|
||||
- 🛡️ Контроль обязательной подписки на канал (отключает подписку при отписке)
|
||||
- 💎 Автовыдача сквада из пула выбранных
|
||||
|
||||
💰 **Платежи и баланс**
|
||||
- ⭐ Telegram Stars
|
||||
@@ -742,7 +630,7 @@ LOG_FILE=logs/bot.log
|
||||
- 💬 Быстрые ссылки на поддержку
|
||||
|
||||
🧩 **Бонусы и промо**
|
||||
- 🎫 Промокоды на деньги, дни, триал подписку
|
||||
- 🎫 Промокоды на деньги, дни, триал подписку, промогруппу
|
||||
- 🎁 **Персональные промо-предложения** от админов
|
||||
- 💰 **Тестовый доступ к серверам** через промо-акции
|
||||
- 💸 **Автоматические скидки** при оплате и автопродлении
|
||||
@@ -751,7 +639,7 @@ LOG_FILE=logs/bot.log
|
||||
- 🔗 Генерация реферальных ссылок и QR кодов
|
||||
|
||||
💎 **Промо-группы и скидки**
|
||||
- 🏷️ **Система промогрупп** с индивидуальными скидками
|
||||
- 🏷️ **Система промогрупп** с индивидуальными скидками с приоритетами
|
||||
- 💰 Скидки на серверы, трафик и устройства
|
||||
- 📊 **Скидочные уровни за траты** - прозрачная система лояльности
|
||||
- 📈 Автоматическое повышение уровня при достижении порога
|
||||
@@ -782,7 +670,7 @@ LOG_FILE=logs/bot.log
|
||||
- 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность)
|
||||
|
||||
👥 **Управление пользователями**
|
||||
- 🔍 Поиск, фильтры и детальные карточки
|
||||
- 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры
|
||||
- 💰 Ручное изменение баланса
|
||||
- 📱 Изменение лимитов устройств, трафика, серверов
|
||||
- 🔄 Сброс HWID и перегенерация подписки
|
||||
@@ -818,7 +706,6 @@ LOG_FILE=logs/bot.log
|
||||
- 💬 Автоматические сообщения о задолженностях
|
||||
|
||||
🧰 **Обслуживание и DevOps**
|
||||
- 🛠️ `install_bot.sh` - **интерактивное меню управления**
|
||||
- 🚧 Ручной и авто-режим техработ
|
||||
- 🗒️ Просмотр системных логов и health-check
|
||||
- 🔄 **Автосинхронизация Remnawave** по расписанию и при старте бота
|
||||
@@ -859,8 +746,9 @@ LOG_FILE=logs/bot.log
|
||||
- 🧠 **Асинхронная архитектура** - aiogram 3, PostgreSQL/SQLite, Redis и очереди задач
|
||||
- 🌐 **Мультиязычность** - локализации RU/EN, быстрый выбор языка
|
||||
- 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между серверами
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопрвоерки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
|
||||
### 🌐 Веб-API и мини-приложение
|
||||
|
||||
@@ -868,7 +756,7 @@ LOG_FILE=logs/bot.log
|
||||
- 🔑 **Управление API-ключами** - выпуск, отзыв, реактивация токенов
|
||||
- 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram
|
||||
- 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA)
|
||||
- 🧭 **App Config** - централизованная раздача ссылок на клиенты
|
||||
- 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты
|
||||
- 🪝 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем
|
||||
- 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики
|
||||
|
||||
@@ -961,9 +849,9 @@ ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # ID топика для тикет
|
||||
## 🐛 Устранение неполадок
|
||||
|
||||
### 🏥 Health Checks
|
||||
- **Основной**: `http://localhost:8081/health`
|
||||
- **YooKassa**: `http://localhost:8082/health`
|
||||
- **Pal24**: `http://localhost:8084/health`
|
||||
- **Unified сервер**: `http://localhost:8080/health/unified` (или `/health`, если административное API отключено)
|
||||
- **Telegram webhook**: `http://localhost:8080/health/telegram-webhook`
|
||||
- **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks`
|
||||
|
||||
### 🔧 Полезные команды
|
||||
```bash
|
||||
@@ -998,7 +886,7 @@ docker system prune
|
||||
|----------|-------------|---------|
|
||||
| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет |
|
||||
| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL |
|
||||
| **Webhook не работает** | Проверь порты 8081/8082/8084 | Настрой прокси-сервер |
|
||||
| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет |
|
||||
| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` |
|
||||
| **Корзина не сохраняется** | `docker compose ps redis` | Проверь статус Redis |
|
||||
| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах |
|
||||
@@ -1082,7 +970,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
|
||||
### 📚 **Полезные ресурсы**
|
||||
|
||||
- **📖 [Remnawave Docs](https://docs.remna.st)** - документация панели
|
||||
- **📖 [https://docs.remna.st](https://docs.rw)))** - документация панели
|
||||
- **🤖 [Telegram Bot API](https://core.telegram.org/bots/api)** - API ботов
|
||||
- **🐳 [Docker Guide](https://docs.docker.com/get-started/)** - обучение Docker
|
||||
- **🛡️ [Reverse Proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)** - защита панели
|
||||
@@ -1173,28 +1061,11 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
### 🚧 **В разработке**
|
||||
|
||||
- 🌎 **Веб-панель** - полноценная административная панель
|
||||
- 📊 **Расширенная аналитика** - больше метрик и графиков
|
||||
- 🔄 **API для интеграций** - подключение внешних сервисов
|
||||
- 🎨 **Темы оформления** - кастомизация интерфейса Mini App
|
||||
|
||||
### ✅ **Недавно добавлено**
|
||||
|
||||
- 💳 **WATA** - оплата банковскими картами
|
||||
- 🔄 **Автосинхронизация Remnawave** - фоновая синхронизация серверов
|
||||
- 🛒 **Умная корзина** - сохранение параметров подписки
|
||||
- 🏗️ **Модульная архитектура** - подписок и платежей
|
||||
- 🖥️ **Полноценный личный кабинет** в Mini App
|
||||
- 💎 **Промо-группы и скидочные уровни** - система лояльности
|
||||
- 🎁 **Персональные промо-предложения** - таргетированные акции
|
||||
- 📄 **Система управления контентом** - политика, оферта, FAQ
|
||||
- 🎫 **Система тикетов** - поддержка пользователей
|
||||
- 📊 **Мониторинг серверов** - интеграция с XrayChecker
|
||||
- 🛡️ **Защита от блокировок** - антифрод система
|
||||
- 🎨 **Темы оформления** - Новая тема интерфейса Mini App by https://t.me/arpicme
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
Проект распространяется под лицензией **MIT**
|
||||
@@ -1238,66 +1109,8 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Быстрые команды
|
||||
|
||||
### 📦 Установка и запуск
|
||||
```bash
|
||||
# Автоустановка (рекомендуется)
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
chmod +x install_bot.sh
|
||||
./install_bot.sh
|
||||
|
||||
# Ручной запуск
|
||||
docker compose up -d
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 🔄 Обновление
|
||||
```bash
|
||||
# Через install_bot.sh (с автобэкапом)
|
||||
./install_bot.sh
|
||||
# Выбрать: 4. 🔄 Обновить проект из Git
|
||||
|
||||
# Ручное обновление
|
||||
git pull
|
||||
docker compose down
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 💾 Бэкап и восстановление
|
||||
```bash
|
||||
# Создать бэкап через install_bot.sh
|
||||
./install_bot.sh
|
||||
# Выбрать: 5. 💾 Создать резервную копию
|
||||
|
||||
# Восстановить бэкап
|
||||
./install_bot.sh
|
||||
# Выбрать: 6. 📦 Восстановить из бэкапа
|
||||
```
|
||||
|
||||
### 📊 Мониторинг
|
||||
```bash
|
||||
# Статус сервисов
|
||||
docker compose ps
|
||||
|
||||
# Логи бота
|
||||
docker compose logs -f bot
|
||||
|
||||
# Проверка здоровья
|
||||
curl http://localhost:8081/health
|
||||
|
||||
# Использование ресурсов
|
||||
docker stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Статистика проекта
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -1316,8 +1129,6 @@ docker stats
|
||||
|
||||
## 🎯 Ключевые особенности в цифрах
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| 💳 **Платёжных систем** | 8 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, WATA) |
|
||||
@@ -1329,8 +1140,6 @@ docker stats
|
||||
| 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) |
|
||||
| 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором |
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🔥 Почему выбирают Bedolaga?
|
||||
|
||||
+2
-3
@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
|
||||
# Открыть только необходимые порты
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
|
||||
ufw deny 8082/tcp
|
||||
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
|
||||
```
|
||||
|
||||
### 📊 Мониторинг безопасности
|
||||
@@ -283,7 +282,7 @@ services:
|
||||
|
||||
# Не пробрасывайте порты наружу без необходимости
|
||||
ports:
|
||||
- "127.0.0.1:8081:8081" # Только localhost
|
||||
- "127.0.0.1:8080:8080" # Только localhost
|
||||
|
||||
# Read-only root filesystem
|
||||
read_only: true
|
||||
|
||||
+8
-5
@@ -182,11 +182,14 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
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("Мониторинг техработ отключен настройками")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
+153
-36
@@ -7,6 +7,7 @@ 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
|
||||
@@ -60,6 +61,8 @@ 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"
|
||||
|
||||
@@ -73,6 +76,7 @@ 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"
|
||||
@@ -123,10 +127,12 @@ 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 = ""
|
||||
@@ -142,7 +148,6 @@ 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"
|
||||
|
||||
@@ -154,8 +159,10 @@ class Settings(BaseSettings):
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_MONITORING_ENABLED: bool = True
|
||||
MAINTENANCE_RETRY_ATTEMPTS: int = 1
|
||||
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
@@ -284,6 +291,7 @@ 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"
|
||||
@@ -312,6 +320,13 @@ 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"
|
||||
@@ -550,6 +565,34 @@ 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:
|
||||
@@ -797,9 +840,35 @@ 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_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
@@ -954,6 +1023,13 @@ 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
|
||||
|
||||
@@ -996,6 +1072,9 @@ 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
|
||||
@@ -1087,34 +1166,7 @@ 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,
|
||||
@@ -1122,8 +1174,6 @@ 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:
|
||||
@@ -1381,15 +1431,82 @@ 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",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Set, 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
|
||||
from app.database.models import PromoGroup, User, UserPromoGroup
|
||||
|
||||
|
||||
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.is_default.desc(), PromoGroup.name)
|
||||
.order_by(PromoGroup.priority.desc(), PromoGroup.name)
|
||||
)
|
||||
|
||||
if offset:
|
||||
@@ -89,6 +89,7 @@ async def create_promo_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
priority: int = 0,
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
@@ -110,6 +111,7 @@ 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)),
|
||||
@@ -152,6 +154,7 @@ 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,
|
||||
@@ -162,6 +165,8 @@ 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:
|
||||
@@ -228,11 +233,42 @@ 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()
|
||||
|
||||
|
||||
@@ -13,12 +13,50 @@ 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))
|
||||
.options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
.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,
|
||||
@@ -27,7 +65,8 @@ async def create_promocode(
|
||||
subscription_days: int = 0,
|
||||
max_uses: int = 1,
|
||||
valid_until: Optional[datetime] = None,
|
||||
created_by: Optional[int] = None
|
||||
created_by: Optional[int] = None,
|
||||
promo_group_id: Optional[int] = None
|
||||
) -> PromoCode:
|
||||
|
||||
promocode = PromoCode(
|
||||
@@ -37,14 +76,18 @@ async def create_promocode(
|
||||
subscription_days=subscription_days,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=created_by
|
||||
created_by=created_by,
|
||||
promo_group_id=promo_group_id
|
||||
)
|
||||
|
||||
db.add(promocode)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
|
||||
if promo_group_id:
|
||||
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
|
||||
else:
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
return promocode
|
||||
|
||||
|
||||
@@ -53,9 +96,9 @@ async def use_promocode(
|
||||
promocode_id: int,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
|
||||
|
||||
try:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
return False
|
||||
|
||||
@@ -143,14 +186,17 @@ async def get_promocodes_list(
|
||||
limit: int = 50,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[PromoCode]:
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.uses))
|
||||
|
||||
|
||||
query = select(PromoCode).options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ 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:
|
||||
|
||||
@@ -80,6 +81,7 @@ 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,
|
||||
)
|
||||
|
||||
@@ -260,8 +262,15 @@ async def update_server_squad(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
|
||||
"display_name",
|
||||
"original_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}
|
||||
|
||||
@@ -11,10 +11,12 @@ 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__)
|
||||
|
||||
@@ -41,13 +43,14 @@ async def create_trial_subscription(
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: int = None,
|
||||
device_limit: Optional[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
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
if not squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
@@ -126,13 +129,16 @@ async def create_paid_subscription(
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
device_limit: Optional[int] = None,
|
||||
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,
|
||||
@@ -190,10 +196,27 @@ 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(
|
||||
@@ -202,27 +225,15 @@ async def extend_subscription(
|
||||
subscription.end_date,
|
||||
)
|
||||
elif subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
subscription.end_date = subscription.end_date + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
subscription.end_date = current_time + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
|
||||
|
||||
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
|
||||
# УДАЛЕНО: Автоматическая конвертация триала по длительности
|
||||
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
|
||||
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
|
||||
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
|
||||
@@ -910,7 +921,7 @@ async def get_subscription_renewal_cost(
|
||||
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),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
@@ -1127,7 +1138,13 @@ async def check_and_update_subscription_status(
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
|
||||
logger.info(
|
||||
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
format_local_datetime(subscription.end_date),
|
||||
format_local_datetime(current_time),
|
||||
)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
@@ -1144,6 +1161,63 @@ 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,
|
||||
|
||||
+165
-15
@@ -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
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.models import (
|
||||
@@ -15,6 +15,7 @@ from app.database.models import (
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
@@ -38,14 +39,16 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
@@ -56,26 +59,64 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.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.promo_group))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
@@ -122,6 +163,57 @@ 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,
|
||||
@@ -500,7 +592,11 @@ async def get_users_list(
|
||||
order_by_purchase_count: bool = False
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
query = select(User).options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
@@ -514,7 +610,14 @@ async def get_users_list(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -588,7 +691,15 @@ async def get_users_list(
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
@@ -611,7 +722,14 @@ async def get_users_count(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -680,12 +798,22 @@ 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())
|
||||
)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
|
||||
@@ -693,7 +821,11 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
|
||||
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
|
||||
@@ -738,7 +870,15 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
|
||||
return []
|
||||
|
||||
result = await db.execute(query.order_by(User.id))
|
||||
return result.scalars().unique().all()
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
@@ -748,6 +888,8 @@ 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(
|
||||
@@ -757,7 +899,15 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user: User) -> bool:
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""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
|
||||
+60
-11
@@ -67,9 +67,10 @@ class TransactionType(Enum):
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
PROMO_GROUP = "promo_group"
|
||||
|
||||
|
||||
class PaymentMethod(Enum):
|
||||
@@ -418,6 +419,7 @@ class PromoGroup(Base):
|
||||
|
||||
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)
|
||||
@@ -429,6 +431,7 @@ 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,
|
||||
@@ -492,6 +495,22 @@ 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"
|
||||
|
||||
@@ -529,23 +548,51 @@ 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=False, index=True)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, 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:
|
||||
if not self.promo_group:
|
||||
primary_group = self.get_primary_promo_group()
|
||||
if not primary_group:
|
||||
return 0
|
||||
return self.promo_group.get_discount_percent(category, period_days)
|
||||
return primary_group.get_discount_percent(category, period_days)
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
@@ -793,13 +840,15 @@ 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:
|
||||
|
||||
@@ -3459,6 +3459,219 @@ 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("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -3620,6 +3833,34 @@ 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:
|
||||
|
||||
Vendored
+1
-1
@@ -163,7 +163,7 @@ class RemnaWaveAPI:
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = self._prepare_auth_headers()
|
||||
|
||||
|
||||
@@ -42,7 +42,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "🤖 Основные",
|
||||
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
|
||||
"icon": "🤖",
|
||||
"categories": ("CORE", "CHANNEL"),
|
||||
"categories": (
|
||||
"CORE",
|
||||
"CHANNEL",
|
||||
"TIMEZONE",
|
||||
"DATABASE",
|
||||
"POSTGRES",
|
||||
"SQLITE",
|
||||
"REDIS",
|
||||
"REMNAWAVE",
|
||||
),
|
||||
},
|
||||
"support": {
|
||||
"title": "💬 Поддержка",
|
||||
@@ -56,6 +65,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
"PAYMENT_VERIFICATION",
|
||||
"YOOKASSA",
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
@@ -114,18 +124,6 @@ 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 и внешние метрики.",
|
||||
@@ -142,13 +140,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "⚡ Расширенные",
|
||||
"description": "Web API, webhook, логирование, модерация и режим отладки.",
|
||||
"icon": "⚡",
|
||||
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
|
||||
},
|
||||
"external_admin": {
|
||||
"title": "🛡️ Внешняя админка",
|
||||
"description": "Токен, по которому внешняя админка проверяет запросы.",
|
||||
"icon": "🛡️",
|
||||
"categories": ("EXTERNAL_ADMIN",),
|
||||
"categories": (
|
||||
"WEB_API",
|
||||
"WEBHOOK",
|
||||
"LOG",
|
||||
"MODERATION",
|
||||
"DEBUG",
|
||||
"EXTERNAL_ADMIN",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -161,12 +160,9 @@ 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(
|
||||
@@ -313,7 +309,6 @@ 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 ("⚪", "Бонусы не заданы")
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ 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"
|
||||
@@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
).format(
|
||||
days=campaign.subscription_duration_days or 0,
|
||||
traffic=traffic_text,
|
||||
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
devices=device_limit,
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices(
|
||||
campaign_edit_message_is_caption=is_caption,
|
||||
)
|
||||
|
||||
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
current_devices = campaign.subscription_device_limit
|
||||
if current_devices is None:
|
||||
current_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
await callback.message.edit_text(
|
||||
(
|
||||
|
||||
@@ -449,4 +449,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(
|
||||
admin_commands_help,
|
||||
Command("admin_help")
|
||||
)
|
||||
)
|
||||
|
||||
@@ -74,6 +74,7 @@ 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}
|
||||
@@ -233,7 +234,11 @@ 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]}")
|
||||
|
||||
|
||||
@@ -764,48 +764,69 @@ async def confirm_broadcast(
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
# Ограничение на количество одновременных отправок
|
||||
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(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
text=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(
|
||||
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}")
|
||||
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)
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
|
||||
@@ -1027,6 +1027,20 @@ 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)
|
||||
@@ -1075,4 +1089,4 @@ async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
dp.include_router(router)
|
||||
dp.include_router(router)
|
||||
|
||||
@@ -211,6 +211,14 @@ 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
|
||||
|
||||
@@ -294,6 +302,7 @@ 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)
|
||||
@@ -318,6 +327,15 @@ 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(
|
||||
@@ -640,6 +658,32 @@ 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,
|
||||
@@ -772,6 +816,7 @@ 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"],
|
||||
@@ -862,6 +907,12 @@ 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(
|
||||
@@ -935,6 +986,48 @@ 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(
|
||||
@@ -1158,8 +1251,9 @@ 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.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
f"{index}. {user_link} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
)
|
||||
body = "\n".join(lines)
|
||||
|
||||
@@ -1181,6 +1275,7 @@ 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()
|
||||
|
||||
@@ -1323,6 +1418,10 @@ 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,
|
||||
@@ -1345,6 +1444,10 @@ 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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
@@ -1896,66 +1897,94 @@ 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
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
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}",
|
||||
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,
|
||||
},
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
])
|
||||
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 = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
])
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
return sent, failed
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ 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
|
||||
delete_promocode, get_promocode_by_id
|
||||
)
|
||||
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
|
||||
|
||||
@@ -81,16 +82,24 @@ async def show_promocodes_list(
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.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"
|
||||
|
||||
@@ -129,15 +138,20 @@ async def show_promocode_management(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, 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": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.get(promo.type, "🎫")
|
||||
|
||||
text = f"""
|
||||
🎫 <b>Управление промокодом</b>
|
||||
|
||||
@@ -145,12 +159,17 @@ 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"
|
||||
|
||||
@@ -200,8 +219,8 @@ async def show_promocode_edit_menu(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -445,13 +464,14 @@ async def select_promocode_type(
|
||||
state: FSMContext
|
||||
):
|
||||
promo_type = callback.data.split('_')[-1]
|
||||
|
||||
|
||||
type_names = {
|
||||
"balance": "💰 Пополнение баланса",
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка"
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка",
|
||||
"group": "🏷️ Промогруппа"
|
||||
}
|
||||
|
||||
|
||||
await state.update_data(promocode_type=promo_type)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -509,6 +529,77 @@ 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
|
||||
@@ -557,8 +648,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 db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -643,8 +734,8 @@ async def handle_edit_uses(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -708,17 +799,20 @@ 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
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
|
||||
"group": PromoCodeType.PROMO_GROUP
|
||||
}
|
||||
|
||||
|
||||
promocode = await create_promocode(
|
||||
db=db,
|
||||
code=code,
|
||||
@@ -727,27 +821,31 @@ 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
|
||||
created_by=db_user.id,
|
||||
promo_group_id=promo_group_id if promo_type == "group" else None
|
||||
)
|
||||
|
||||
type_names = {
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка"
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка",
|
||||
"group": "Промогруппа"
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
@@ -775,8 +873,8 @@ async def handle_edit_expiry(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -822,8 +920,8 @@ async def toggle_promocode_status(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -849,8 +947,8 @@ async def confirm_delete_promocode(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -897,8 +995,8 @@ async def delete_promocode_confirmed(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -921,8 +1019,8 @@ async def show_promocode_stats(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -1007,6 +1105,7 @@ 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_"))
|
||||
|
||||
@@ -68,7 +68,6 @@ 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 '❌ Отключены'}
|
||||
|
||||
@@ -105,7 +104,6 @@ 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>
|
||||
@@ -190,7 +188,6 @@ 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}%
|
||||
|
||||
@@ -421,7 +421,8 @@ 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)
|
||||
lines.append(f"{index}. {safe_user_name}")
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
|
||||
lines.append(f"{index}. {user_link}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -174,12 +175,24 @@ 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_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
|
||||
username_value = ticket.user.username if ticket.user else None
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
|
||||
ticket_text += f"📱 Username: @{username_display}\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"📝 Заголовок: {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"
|
||||
@@ -221,15 +234,11 @@ async def view_admin_ticket(
|
||||
pass
|
||||
# Кнопки ЛС и профиль
|
||||
try:
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
if ticket.user and ticket.user.telegram_id and ticket.user.username:
|
||||
safe_username = html.escape(ticket.user.username)
|
||||
buttons_row = []
|
||||
# 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}"
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
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:
|
||||
@@ -750,7 +759,21 @@ 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\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"
|
||||
if updated.is_user_reply_blocked:
|
||||
if updated.user_reply_block_permanent:
|
||||
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
|
||||
@@ -778,12 +801,10 @@ async def handle_admin_block_duration_input(
|
||||
pass
|
||||
# Кнопки ЛС и профиль при обновлении карточки
|
||||
try:
|
||||
if updated.user and updated.user.telegram_id:
|
||||
if updated.user and updated.user.telegram_id and updated.user.username:
|
||||
safe_username = html.escape(updated.user.username)
|
||||
buttons_row = []
|
||||
if updated.user.username:
|
||||
pm_url = f"tg://resolve?domain={updated.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
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))
|
||||
|
||||
+595
-81
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Tuple
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -10,7 +11,12 @@ 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
|
||||
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.campaign import (
|
||||
get_campaign_registration_by_user,
|
||||
get_campaign_statistics,
|
||||
@@ -35,6 +41,9 @@ 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__)
|
||||
|
||||
@@ -999,7 +1008,8 @@ async def _render_user_subscription_overview(
|
||||
subscription = profile["subscription"]
|
||||
|
||||
text = "📱 <b>Подписка и настройки пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\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"
|
||||
|
||||
keyboard = []
|
||||
|
||||
@@ -1159,7 +1169,8 @@ async def show_user_transactions(
|
||||
transactions = await get_user_transactions(db, user_id, limit=10)
|
||||
|
||||
text = f"💳 <b>Транзакции пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\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"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
|
||||
if transactions:
|
||||
@@ -1436,16 +1447,41 @@ async def show_user_management(
|
||||
else:
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE)
|
||||
|
||||
if user.promo_group:
|
||||
promo_group = user.promo_group
|
||||
# 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))
|
||||
)
|
||||
sections.append(
|
||||
texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format(
|
||||
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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -1486,6 +1522,396 @@ 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,
|
||||
@@ -1494,20 +1920,43 @@ async def _render_user_promo_group(
|
||||
) -> None:
|
||||
texts = get_texts(language)
|
||||
|
||||
current_group = user.promo_group
|
||||
# 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))
|
||||
|
||||
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=current_group.server_discount_percent,
|
||||
traffic=current_group.traffic_discount_percent,
|
||||
devices=current_group.device_discount_percent,
|
||||
servers=primary_group.server_discount_percent,
|
||||
traffic=primary_group.traffic_discount_percent,
|
||||
devices=primary_group.device_discount_percent,
|
||||
)
|
||||
current_group_id = current_group.id
|
||||
|
||||
# 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
|
||||
else:
|
||||
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE
|
||||
current_group_id = None
|
||||
current_line = texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_NONE",
|
||||
"У пользователя нет промогрупп",
|
||||
)
|
||||
discount_line = ""
|
||||
|
||||
text = (
|
||||
f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n"
|
||||
@@ -1521,7 +1970,7 @@ async def _render_user_promo_group(
|
||||
reply_markup=get_user_promo_group_keyboard(
|
||||
promo_groups,
|
||||
user.id,
|
||||
current_group_id,
|
||||
user_group_ids, # Pass list of all group IDs
|
||||
language
|
||||
)
|
||||
)
|
||||
@@ -1559,6 +2008,13 @@ 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])
|
||||
@@ -1571,49 +2027,52 @@ async def set_user_promo_group(
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if user.promo_group_id == group_id:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ALREADY, show_alert=True)
|
||||
return
|
||||
# Check if user already has this group
|
||||
has_group = await has_user_promo_group(db, user_id, group_id)
|
||||
|
||||
user_service = UserService()
|
||||
success, updated_user, new_group, old_group = await user_service.update_user_promo_group(
|
||||
db,
|
||||
user_id,
|
||||
group_id
|
||||
)
|
||||
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
|
||||
|
||||
if not success or not updated_user or not new_group:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
|
||||
return
|
||||
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
|
||||
|
||||
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, 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,
|
||||
)
|
||||
await _render_user_promo_group(callback.message, db_user.language, user, promo_groups)
|
||||
|
||||
|
||||
|
||||
@@ -1882,9 +2341,10 @@ 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]:
|
||||
text += f"👤 {user.full_name}\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"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
last_activity_display = (
|
||||
format_time_ago(user.last_activity, db_user.language)
|
||||
@@ -1986,7 +2446,8 @@ async def show_user_statistics(
|
||||
campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id)
|
||||
|
||||
text = f"📊 <b>Статистика пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\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"<b>Основная информация:</b>\n"
|
||||
text += f"• Дней с регистрации: {profile['registration_days']}\n"
|
||||
@@ -3338,7 +3799,15 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in
|
||||
logger.error(f"У пользователя {user_id} уже есть подписка")
|
||||
return False
|
||||
|
||||
subscription = await create_trial_subscription(db, user_id)
|
||||
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_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
@@ -3382,12 +3851,20 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
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
|
||||
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.DEFAULT_DEVICE_LIMIT,
|
||||
device_limit=device_limit,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -3591,10 +4068,13 @@ async def admin_buy_subscription(
|
||||
])
|
||||
|
||||
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\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"💰 Баланс пользователя: {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 or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -3680,12 +4160,15 @@ async def admin_buy_subscription_confirm(
|
||||
return
|
||||
|
||||
text = f"💳 <b>Подтверждение покупки подписки</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\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"📅 Период подписки: {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 or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -3832,40 +4315,54 @@ 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:
|
||||
remnawave_user = await api.update_user(
|
||||
update_kwargs = dict(
|
||||
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 = f"user_{target_user.telegram_id}"
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
)
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
remnawave_user = await api.create_user(
|
||||
create_kwargs = dict(
|
||||
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
|
||||
@@ -3882,9 +4379,10 @@ 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.full_name} (ID: {target_user.telegram_id})\n"
|
||||
f"👤 {target_user_link} (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=[
|
||||
@@ -3892,7 +4390,8 @@ async def admin_buy_subscription_execute(
|
||||
text="⬅️ Назад к подписке",
|
||||
callback_data=f"admin_user_subscription_{user_id}"
|
||||
)]
|
||||
])
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -4104,12 +4603,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.startswith("admin_user_promo_group_") & ~F.data.contains("_set_") & ~F.data.contains("_toggle_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_set_")
|
||||
F.data.startswith("admin_user_promo_group_toggle_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -4122,6 +4621,21 @@ 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_")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -18,6 +19,92 @@ 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>
|
||||
@@ -202,6 +289,12 @@ 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:
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_buttons:
|
||||
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
|
||||
|
||||
|
||||
+124
-17
@@ -8,6 +8,8 @@ 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
|
||||
@@ -27,34 +29,61 @@ logger = logging.getLogger(__name__)
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
def get_quick_amount_buttons(language: str) -> list:
|
||||
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
|
||||
"""
|
||||
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]
|
||||
|
||||
periods = periods[:6] # Limit to 6 periods
|
||||
|
||||
for period in periods:
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
price_kopeks = getattr(settings, price_attr)
|
||||
price_rubles = price_kopeks // 100
|
||||
|
||||
callback_data = f"quick_amount_{price_kopeks}"
|
||||
|
||||
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}"
|
||||
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{price_rubles} ₽ ({period} дней)",
|
||||
text=button_text,
|
||||
callback_data=callback_data
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i:i + 2])
|
||||
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@@ -180,25 +209,103 @@ 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
|
||||
)
|
||||
|
||||
current_tariff_desc = f"📱 Подписка: {len(current_connected_squads)} серверов, {current_traffic} ГБ, {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(
|
||||
payment_text,
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.edit_caption(
|
||||
payment_text,
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -208,7 +315,7 @@ async def show_payment_methods(
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
payment_text,
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -796,4 +903,4 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
handle_topup_amount_callback,
|
||||
F.data.startswith("topup_amount|")
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
|
||||
@@ -264,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)
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -38,6 +38,7 @@ 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__)
|
||||
@@ -952,7 +953,8 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
actual_status = (subscription.actual_status or "").lower()
|
||||
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
|
||||
days_left = 0
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
@@ -968,10 +970,10 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date_text)
|
||||
).format(end_date=end_date_text or "—")
|
||||
|
||||
if actual_status == "trial":
|
||||
if days_left > 1:
|
||||
if days_left > 1 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
@@ -990,7 +992,7 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
)
|
||||
|
||||
if actual_status == "active":
|
||||
if days_left > 7:
|
||||
if days_left > 7 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
|
||||
+47
-26
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
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
|
||||
@@ -32,6 +31,45 @@ 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,
|
||||
@@ -40,9 +78,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(
|
||||
@@ -52,31 +90,14 @@ async def process_promocode(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, db_user.id, code)
|
||||
|
||||
|
||||
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
|
||||
|
||||
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,
|
||||
@@ -85,13 +106,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()
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ 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
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
resolve_simple_subscription_device_limit,
|
||||
)
|
||||
from app.utils.pricing_utils import compute_simple_subscription_price
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,15 +38,17 @@ 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": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
"device_limit": device_limit,
|
||||
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
}
|
||||
@@ -94,63 +99,115 @@ async def start_simple_subscription_purchase(
|
||||
can_pay_from_balance,
|
||||
)
|
||||
|
||||
# Проверяем, является ли у пользователя текущая подписка активной платной подпиской
|
||||
has_active_paid_subscription = False
|
||||
trial_notice = ""
|
||||
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)
|
||||
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)
|
||||
|
||||
server_label = _get_simple_subscription_server_label(
|
||||
texts,
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
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 "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
|
||||
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 = "\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 = [
|
||||
[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(
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
|
||||
)
|
||||
])
|
||||
else:
|
||||
message_lines.append(
|
||||
"Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
|
||||
)
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
|
||||
if trial_notice:
|
||||
message_text = f"{trial_notice}\n\n{message_text}"
|
||||
|
||||
keyboard_rows.extend(methods_keyboard.inline_keyboard)
|
||||
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 = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
|
||||
# Устанавливаем соответствующее состояние
|
||||
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 callback.answer()
|
||||
|
||||
|
||||
@@ -318,6 +375,15 @@ 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,
|
||||
@@ -375,7 +441,10 @@ 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,
|
||||
@@ -384,6 +453,16 @@ 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]
|
||||
|
||||
@@ -433,16 +512,28 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
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"🔗 Для подключения перейдите в раздел 'Подключиться'"
|
||||
)
|
||||
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)
|
||||
@@ -619,19 +710,31 @@ async def handle_simple_subscription_other_payment_methods(
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
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"
|
||||
+ (
|
||||
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)}",
|
||||
"",
|
||||
(
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
|
||||
if can_pay_from_balance
|
||||
else "Выберите подходящий способ оплаты:"
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
|
||||
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
keyboard_rows = []
|
||||
@@ -673,6 +776,15 @@ 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:
|
||||
@@ -854,14 +966,25 @@ async def handle_simple_subscription_payment_method(
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
# Подготавливаем текст сообщения
|
||||
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"
|
||||
)
|
||||
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)
|
||||
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
@@ -1893,6 +2016,281 @@ 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):
|
||||
"""Регистрирует обработчики простой покупки подписки."""
|
||||
|
||||
@@ -1901,6 +2299,11 @@ 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"
|
||||
|
||||
+186
-81
@@ -37,6 +37,7 @@ 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
|
||||
|
||||
@@ -44,6 +45,17 @@ 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,
|
||||
@@ -88,15 +100,15 @@ async def handle_potential_referral_code(
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
|
||||
|
||||
logger.info(f"🔍 REFERRAL/PROMO 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
|
||||
@@ -113,37 +125,73 @@ 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 not referrer:
|
||||
await message.answer(texts.t(
|
||||
"REFERRAL_CODE_INVALID_HELP",
|
||||
"❌ Неверный реферальный код.\n\n"
|
||||
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
|
||||
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
))
|
||||
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)
|
||||
|
||||
return True
|
||||
|
||||
data['referral_code'] = potential_code
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
# Если реферальный код не найден, проверяем промокод
|
||||
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)
|
||||
|
||||
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)
|
||||
texts.t(
|
||||
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
|
||||
"✅ Промокод принят! Он будет активирован после завершения регистрации."
|
||||
)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после ввода реферального кода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
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",
|
||||
))
|
||||
return True
|
||||
|
||||
|
||||
@@ -224,7 +272,11 @@ 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
|
||||
@@ -240,7 +292,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
if pending_start_payload is not None:
|
||||
if state_needs_update:
|
||||
await state.set_data(data)
|
||||
|
||||
if start_parameter:
|
||||
@@ -266,7 +318,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:
|
||||
if campaign and not campaign_notification_sent:
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_campaign_link_visit_notification(
|
||||
@@ -338,11 +390,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
f"Ошибка отправки уведомления о рекламной кампании: {e}"
|
||||
)
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -678,31 +728,57 @@ 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: Обработка реферального кода: {message.text}")
|
||||
|
||||
|
||||
logger.info(f"🎫 REFERRAL/PROMO: Обработка кода: {message.text}")
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
referral_code = message.text.strip()
|
||||
code = message.text.strip()
|
||||
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
# Сначала проверяем, является ли это реферальным кодом
|
||||
referrer = await get_user_by_referral_code(db, code)
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
|
||||
logger.info(f"✅ Реферальный код применен")
|
||||
else:
|
||||
await message.answer(texts.t("REFERRAL_CODE_INVALID", "❌ Неверный реферальный код"))
|
||||
logger.info(f"❌ Неверный реферальный код")
|
||||
logger.info(f"✅ Реферальный код применен: {code}")
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
# Если реферальный код не найден, проверяем промокод
|
||||
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
|
||||
|
||||
|
||||
async def process_referral_code_skip(
|
||||
@@ -761,11 +837,9 @@ async def complete_registration_from_callback(
|
||||
|
||||
await db.refresh(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
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -943,11 +1017,9 @@ async def complete_registration_from_callback(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1019,11 +1091,9 @@ async def complete_registration(
|
||||
|
||||
await db.refresh(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
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -1149,6 +1219,29 @@ 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:
|
||||
@@ -1201,11 +1294,9 @@ async def complete_registration(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1258,30 +1349,43 @@ 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 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 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 not end_date:
|
||||
return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна")
|
||||
|
||||
days_left = (end_date - current_time).days
|
||||
is_trial = getattr(subscription, "is_trial", False)
|
||||
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", "❓ Статус неизвестен")
|
||||
|
||||
if is_trial:
|
||||
if days_left > 1:
|
||||
if days_left > 1 and end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
@@ -1292,11 +1396,11 @@ def _get_subscription_status(user, texts):
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
if days_left > 7:
|
||||
if days_left > 7 and end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
@@ -1521,8 +1625,9 @@ async def required_sub_channel_check(
|
||||
logger.warning(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
has_active_subscription = bool(user.subscription)
|
||||
subscription_is_active = bool(user.subscription and user.subscription.is_active)
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
@@ -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),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
@@ -202,45 +202,26 @@ 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),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_devices.state:
|
||||
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)
|
||||
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 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)
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
elif current_state == SubscriptionStates.confirming_purchase.state:
|
||||
data = await state.get_data()
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
if settings.is_devices_selection_enabled():
|
||||
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)
|
||||
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:
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
else:
|
||||
from app.handlers.menu import show_main_menu
|
||||
@@ -267,3 +248,37 @@ 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)
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user(
|
||||
if user is None:
|
||||
return 0
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
if promo_group is None:
|
||||
return 0
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ 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,
|
||||
@@ -588,6 +589,11 @@ 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(
|
||||
|
||||
@@ -183,6 +183,13 @@ 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", "⚠️ Эта функция доступна только для платных подписок"),
|
||||
@@ -233,6 +240,13 @@ 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:
|
||||
@@ -379,6 +393,13 @@ 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(
|
||||
@@ -863,6 +884,13 @@ 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
|
||||
|
||||
@@ -77,6 +77,7 @@ 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
|
||||
@@ -158,7 +159,18 @@ async def _prepare_subscription_summary(
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
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
|
||||
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(
|
||||
@@ -241,17 +253,20 @@ async def _prepare_subscription_summary(
|
||||
else:
|
||||
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
|
||||
|
||||
if base_discount_total > 0:
|
||||
details_lines = []
|
||||
|
||||
# Добавляем строку базового периода только если цена не равна 0
|
||||
if base_discount_total > 0 and base_price > 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)})"
|
||||
)
|
||||
else:
|
||||
details_lines.append(base_line)
|
||||
elif base_price_original > 0:
|
||||
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
|
||||
|
||||
details_lines = [base_line]
|
||||
details_lines.append(base_line)
|
||||
|
||||
if total_traffic_price > 0:
|
||||
traffic_line = (
|
||||
@@ -275,7 +290,7 @@ async def _prepare_subscription_summary(
|
||||
f" -{texts.format_price(total_servers_discount)})"
|
||||
)
|
||||
details_lines.append(servers_line)
|
||||
if total_devices_price > 0:
|
||||
if devices_selection_enabled and total_devices_price > 0:
|
||||
devices_line = (
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_devices_price)}"
|
||||
@@ -300,17 +315,28 @@ async def _prepare_subscription_summary(
|
||||
|
||||
details_text = "\n".join(details_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"
|
||||
"Подтверждаете покупку?"
|
||||
)
|
||||
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)
|
||||
|
||||
return summary_text, summary_data
|
||||
|
||||
@@ -327,7 +353,7 @@ async def _build_subscription_period_prompt(
|
||||
if promo_offer_hint:
|
||||
lines.extend(["", promo_offer_hint])
|
||||
|
||||
promo_text = _build_promo_group_discount_text(
|
||||
promo_text = await _build_promo_group_discount_text(
|
||||
db_user,
|
||||
settings.get_available_subscription_periods(),
|
||||
texts=texts,
|
||||
@@ -382,7 +408,18 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
)
|
||||
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
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
|
||||
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
@@ -410,7 +447,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
return 0
|
||||
|
||||
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
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
|
||||
countries_info = await _get_countries_info(subscription.connected_squads)
|
||||
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
|
||||
|
||||
@@ -439,10 +481,21 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
info_text = texts.SUBSCRIPTION_INFO.format(
|
||||
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(
|
||||
status=status_text,
|
||||
type=type_text,
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%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,
|
||||
|
||||
@@ -88,12 +88,12 @@ async def _get_promo_offer_hint(
|
||||
) -> Optional[str]:
|
||||
return await build_promo_offer_hint(db, db_user, texts, percent)
|
||||
|
||||
def _build_promo_group_discount_text(
|
||||
async def _build_promo_group_discount_text(
|
||||
db_user: User,
|
||||
periods: Optional[List[int]] = None,
|
||||
texts=None,
|
||||
) -> str:
|
||||
promo_group = getattr(db_user, "promo_group", None)
|
||||
promo_group = db_user.get_primary_promo_group()
|
||||
|
||||
if not promo_group:
|
||||
return ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
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
|
||||
@@ -80,6 +80,7 @@ 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,
|
||||
@@ -352,12 +353,15 @@ async def select_traffic(
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
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)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
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(
|
||||
@@ -365,8 +369,11 @@ 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
|
||||
|
||||
await callback.answer()
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
|
||||
async def add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
|
||||
+21
-3
@@ -760,6 +760,12 @@ 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", "📊 Статистика"),
|
||||
@@ -821,20 +827,28 @@ 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_id: Optional[int],
|
||||
current_group_ids, # Can be Optional[int] or List[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:
|
||||
prefix = "✅" if current_group_id is not None and group.id == current_group_id else "👥"
|
||||
# Check if user has this group
|
||||
has_group = group.id in current_group_ids
|
||||
prefix = "✅" if has_group 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_set_{user_id}_{group.id}"
|
||||
callback_data=f"admin_user_promo_group_toggle_{user_id}_{group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -881,6 +895,10 @@ 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"
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
+105
-64
@@ -9,7 +9,11 @@ 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.pricing_utils import (
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.price_display import PriceInfo, format_price_button
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
@@ -353,10 +357,13 @@ 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="subscription_resume_checkout",
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -668,7 +675,7 @@ def get_insufficient_balance_keyboard(
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
]
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
@@ -800,7 +807,7 @@ def get_payment_methods_keyboard_with_cart(
|
||||
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -857,36 +864,59 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> 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
|
||||
|
||||
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:
|
||||
if days in period_texts:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=period_texts[days],
|
||||
callback_data=f"period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
# 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}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка "Простая покупка" была убрана из выбора периода подписки
|
||||
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@@ -1402,27 +1432,8 @@ def _get_days_word(days: int) -> str:
|
||||
|
||||
|
||||
|
||||
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)
|
||||
# Deprecated: get_extend_subscription_keyboard() was removed.
|
||||
# Use get_extend_subscription_keyboard_with_prices() instead for personalized pricing.
|
||||
|
||||
|
||||
def get_add_traffic_keyboard(
|
||||
@@ -1959,15 +1970,39 @@ 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=f"📅 {period_display} - {texts.format_price(final_price)}",
|
||||
text=button_text,
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
@@ -2082,33 +2117,39 @@ 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")
|
||||
])
|
||||
|
||||
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, [
|
||||
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.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
|
||||
@@ -314,6 +314,7 @@
|
||||
"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.",
|
||||
@@ -370,7 +371,18 @@
|
||||
"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_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_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:",
|
||||
@@ -856,6 +868,9 @@
|
||||
"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",
|
||||
@@ -1118,7 +1133,11 @@
|
||||
"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 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_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_SKIP": "⏭️ Skip",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
|
||||
@@ -1305,6 +1324,8 @@
|
||||
"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!",
|
||||
@@ -1378,7 +1399,8 @@
|
||||
"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 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_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}{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_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!",
|
||||
@@ -1430,6 +1452,7 @@
|
||||
"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.",
|
||||
|
||||
@@ -314,6 +314,7 @@
|
||||
"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": "Промогруппы не найдены.",
|
||||
@@ -371,6 +372,17 @@
|
||||
"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": "Доступные категории:",
|
||||
@@ -713,6 +725,26 @@
|
||||
"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}%",
|
||||
@@ -856,6 +888,9 @@
|
||||
"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": "❌ Ошибка получения информации об устройствах",
|
||||
@@ -1118,7 +1153,11 @@
|
||||
"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_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_SKIP": "⏭️ Пропустить",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
|
||||
@@ -1305,6 +1344,8 @@
|
||||
"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⚠️ истекает сегодня!",
|
||||
@@ -1378,7 +1419,8 @@
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"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_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\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_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Если возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
@@ -1430,6 +1472,7 @@
|
||||
"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": "❌ Некорректная сумма. Попробуйте ещё раз.",
|
||||
|
||||
@@ -30,12 +30,6 @@ 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)}",
|
||||
@@ -55,12 +49,6 @@ 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)}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram import BaseMiddleware, Bot, types
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -16,6 +17,7 @@ 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__)
|
||||
|
||||
@@ -104,7 +106,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if telegram_id:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
@@ -113,12 +115,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)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
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)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except TelegramBadRequest as e:
|
||||
if "chat not found" in str(e).lower():
|
||||
@@ -127,13 +129,18 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
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) -> None:
|
||||
async def _capture_start_payload(
|
||||
self,
|
||||
state: Optional[FSMContext],
|
||||
event: TelegramObject,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> None:
|
||||
if not state:
|
||||
return
|
||||
|
||||
@@ -159,12 +166,69 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
payload = parts[1]
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if data.get("pending_start_payload") == payload:
|
||||
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,
|
||||
)
|
||||
return
|
||||
|
||||
data["pending_start_payload"] = payload
|
||||
await state.set_data(data)
|
||||
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
|
||||
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
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
async for db in get_db():
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.database.models import (
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,6 +78,20 @@ 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] = []
|
||||
|
||||
@@ -185,26 +200,35 @@ 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
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
|
||||
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
|
||||
📱 Устройства: {trial_device_limit}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -227,28 +251,29 @@ 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.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -264,11 +289,11 @@ class AdminNotificationService:
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -315,7 +340,7 @@ class AdminNotificationService:
|
||||
|
||||
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
|
||||
|
||||
⚙️ <i>Автоматическая проверка обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -340,7 +365,7 @@ class AdminNotificationService:
|
||||
🔄 Следующая попытка через час.
|
||||
⚙️ Проверьте доступность GitHub API и настройки сети.
|
||||
|
||||
⚙️ <i>Система автоматических обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Система автоматических обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -363,13 +388,14 @@ 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 = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -540,15 +566,16 @@ 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.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
|
||||
{promo_block}
|
||||
|
||||
@@ -559,8 +586,8 @@ class AdminNotificationService:
|
||||
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Было до: {format_local_datetime(old_end_date, '%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {format_local_datetime(current_end_date, '%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
@@ -569,7 +596,7 @@ class AdminNotificationService:
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -592,13 +619,14 @@ 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.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -621,7 +649,7 @@ class AdminNotificationService:
|
||||
valid_until = promocode_data.get("valid_until")
|
||||
if valid_until:
|
||||
message_lines.append(
|
||||
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
|
||||
f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}"
|
||||
if isinstance(valid_until, datetime)
|
||||
else f"⏳ Действует до: {valid_until}"
|
||||
)
|
||||
@@ -632,7 +660,7 @@ class AdminNotificationService:
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -686,7 +714,7 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -719,13 +747,14 @@ class AdminNotificationService:
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
|
||||
]
|
||||
@@ -750,7 +779,7 @@ class AdminNotificationService:
|
||||
[
|
||||
"",
|
||||
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -828,9 +857,9 @@ class AdminNotificationService:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
if subscription.is_trial:
|
||||
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
|
||||
return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
elif subscription.is_active:
|
||||
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
|
||||
return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
@@ -901,7 +930,9 @@ class AdminNotificationService:
|
||||
if isinstance(enabled_at, str):
|
||||
from datetime import datetime
|
||||
enabled_at = datetime.fromisoformat(enabled_at)
|
||||
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время включения:</b> {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
|
||||
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
|
||||
message_parts.append("")
|
||||
@@ -913,7 +944,9 @@ class AdminNotificationService:
|
||||
if isinstance(disabled_at, str):
|
||||
from datetime import datetime
|
||||
disabled_at = datetime.fromisoformat(disabled_at)
|
||||
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время отключения:</b> {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
|
||||
if details.get("duration"):
|
||||
duration = details["duration"]
|
||||
@@ -972,9 +1005,10 @@ class AdminNotificationService:
|
||||
else:
|
||||
message_parts.append("Автоматический мониторинг API остановлен.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1020,7 +1054,9 @@ class AdminNotificationService:
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Последняя проверка:</b> {format_local_datetime(last_check, '%H:%M:%S')}"
|
||||
)
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
@@ -1066,9 +1102,10 @@ class AdminNotificationService:
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1095,6 +1132,7 @@ 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": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
@@ -1107,9 +1145,9 @@ class AdminNotificationService:
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -1142,11 +1180,11 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
|
||||
f"📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}",
|
||||
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
|
||||
f"🔗 <b>Рефер:</b> {referrer_info}",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -137,30 +137,50 @@ class BroadcastService:
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
for index, user in enumerate(recipients, start=1):
|
||||
# Ограничение на количество одновременных отправок
|
||||
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):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
failed_count += 1
|
||||
continue
|
||||
batch = recipients[i:i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
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,
|
||||
)
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
|
||||
|
||||
@@ -120,9 +120,9 @@ class AdvertisingCampaignService:
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
traffic_limit = campaign.subscription_traffic_gb
|
||||
device_limit = (
|
||||
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
)
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads:
|
||||
|
||||
@@ -7,6 +7,7 @@ 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__)
|
||||
|
||||
@@ -45,6 +46,9 @@ 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"""
|
||||
🔧 Технические работы!
|
||||
|
||||
@@ -52,7 +56,7 @@ class MaintenanceService:
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
|
||||
🔄 Последняя проверка: {last_check_display}
|
||||
"""
|
||||
else:
|
||||
return settings.get_maintenance_message()
|
||||
@@ -79,7 +83,12 @@ class MaintenanceService:
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
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>"
|
||||
)
|
||||
|
||||
return await notification_service._send_message(formatted_message)
|
||||
|
||||
@@ -152,11 +161,14 @@ 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> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
|
||||
🕐 <b>Время:</b> {enabled_time}
|
||||
|
||||
Обычные пользователи временно не смогут использовать бота."""
|
||||
|
||||
@@ -197,10 +209,13 @@ 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> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
🕐 <b>Время:</b> {notification_time}
|
||||
{duration_str}
|
||||
|
||||
Сервис снова доступен для пользователей."""
|
||||
@@ -223,15 +238,23 @@ class MaintenanceService:
|
||||
await self._load_status_from_cache()
|
||||
|
||||
self._check_task = asyncio.create_task(self._monitoring_loop())
|
||||
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
|
||||
await self._notify_admins(f"""Мониторинг технических работ запущен
|
||||
logger.info(
|
||||
"🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)",
|
||||
settings.get_maintenance_check_interval(),
|
||||
settings.get_maintenance_retry_attempts(),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -260,10 +283,10 @@ 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()
|
||||
api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
@@ -272,63 +295,100 @@ class MaintenanceService:
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
|
||||
async with api:
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
if not self._status.api_status:
|
||||
await self._notify_admins(f"""API Remnawave восстановлено!
|
||||
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 восстановлено!
|
||||
|
||||
✅ <b>Статус:</b> Доступно
|
||||
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🕐 <b>Время восстановления:</b> {recovery_time}
|
||||
🔄 <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
|
||||
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 недоступно!
|
||||
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 недоступно!
|
||||
|
||||
❌ <b>Статус:</b> Недоступно
|
||||
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🕐 <b>Время обнаружения:</b> {detection_time}
|
||||
🔄 <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:
|
||||
await self._notify_admins(f"""Ошибка при проверке API Remnawave
|
||||
error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z")
|
||||
await self._notify_admins(
|
||||
f"""Ошибка при проверке API Remnawave
|
||||
|
||||
❌ <b>Ошибка:</b> {str(e)}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
|
||||
🕐 <b>Время:</b> {error_time}
|
||||
|
||||
Не удалось выполнить проверку доступности API.""", "error")
|
||||
Не удалось выполнить проверку доступности API.""",
|
||||
"error",
|
||||
)
|
||||
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
@@ -398,6 +458,7 @@ API снова отвечает на запросы.""", "success")
|
||||
"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
|
||||
|
||||
@@ -38,7 +38,19 @@ from app.database.crud.user import (
|
||||
subtract_user_balance,
|
||||
cleanup_expired_promo_offer_discounts,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
|
||||
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.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -241,9 +253,6 @@ 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)
|
||||
|
||||
@@ -282,21 +291,34 @@ class MonitoringService:
|
||||
is_active = False
|
||||
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
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(
|
||||
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
|
||||
@@ -372,7 +394,12 @@ class MonitoringService:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
selectinload(Subscription.user)
|
||||
.selectinload(User.user_promo_groups)
|
||||
.selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
@@ -554,7 +581,11 @@ class MonitoringService:
|
||||
)
|
||||
continue
|
||||
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and not is_member:
|
||||
if (
|
||||
subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and subscription.is_trial
|
||||
and not is_member
|
||||
):
|
||||
subscription = await deactivate_subscription(db, subscription)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
@@ -588,7 +619,11 @@ class MonitoringService:
|
||||
subscription.id,
|
||||
"trial_channel_unsubscribed",
|
||||
)
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
|
||||
elif (
|
||||
subscription.status == SubscriptionStatus.DISABLED.value
|
||||
and subscription.is_trial
|
||||
and is_member
|
||||
):
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -1026,7 +1061,7 @@ class MonitoringService:
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
@@ -1070,6 +1105,56 @@ 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>
|
||||
|
||||
@@ -1079,7 +1164,7 @@ class MonitoringService:
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• 30 дней всего за {settings.format_price(min_total_price)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Скорость до 1ГБит/сек
|
||||
@@ -1142,7 +1227,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -1258,7 +1343,7 @@ class MonitoringService:
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
)
|
||||
|
||||
@@ -1335,7 +1420,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
|
||||
expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"),
|
||||
trigger_days=trigger_days or "",
|
||||
)
|
||||
|
||||
@@ -1473,7 +1558,11 @@ class MonitoringService:
|
||||
if now.minute != 0:
|
||||
return
|
||||
|
||||
async with self.subscription_service.api as api:
|
||||
if not self.subscription_service.is_configured:
|
||||
logger.warning("RemnaWave API не настроен. Пропускаем синхронизацию")
|
||||
return
|
||||
|
||||
async with self.subscription_service.get_api_client() as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
|
||||
@@ -83,6 +83,12 @@ 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_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
|
||||
@@ -23,6 +23,7 @@ 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__)
|
||||
@@ -56,14 +57,32 @@ class PaymentCommonMixin:
|
||||
|
||||
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
|
||||
if user:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
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:
|
||||
keyboard_rows.append([
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
])
|
||||
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([
|
||||
|
||||
@@ -211,7 +211,7 @@ class CryptoBotPaymentMixin:
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
@@ -332,7 +332,7 @@ class CryptoBotPaymentMixin:
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
|
||||
@@ -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 = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
@@ -247,6 +247,7 @@ class MulenPayPaymentMixin:
|
||||
user,
|
||||
payment.amount_kopeks,
|
||||
f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽",
|
||||
create_transaction=False,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -269,9 +270,18 @@ class MulenPayPaymentMixin:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
# После коммита отношения пользователя могли быть сброшены, поэтому
|
||||
# повторно загружаем пользователя с предзагрузкой зависимостей
|
||||
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
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
@@ -365,7 +375,7 @@ class MulenPayPaymentMixin:
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
|
||||
@@ -362,7 +362,7 @@ class Pal24PaymentMixin:
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
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 "🔄 Пополнение"
|
||||
@@ -473,7 +473,7 @@ class Pal24PaymentMixin:
|
||||
"BALANCE_TOPUP_CART_BUTTON",
|
||||
"🛒 Продолжить оформление",
|
||||
),
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -430,7 +430,7 @@ class TelegramStarsMixin:
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
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 "🔄 Пополнение"
|
||||
@@ -584,7 +584,7 @@ class TelegramStarsMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -464,7 +464,7 @@ class WataPaymentMixin:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
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 "🔄 Пополнение"
|
||||
@@ -561,7 +561,7 @@ class WataPaymentMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -319,6 +319,7 @@ class YooKassaPaymentMixin:
|
||||
) -> bool:
|
||||
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
@@ -387,9 +388,27 @@ class YooKassaPaymentMixin:
|
||||
user.balance_kopeks += payment.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)
|
||||
# Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой
|
||||
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)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
@@ -425,8 +444,13 @@ 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(
|
||||
user,
|
||||
refreshed_user or user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
@@ -510,7 +534,7 @@ class YooKassaPaymentMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
@@ -619,10 +643,32 @@ 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,
|
||||
user,
|
||||
subscription,
|
||||
full_user or user,
|
||||
subscription_db or subscription,
|
||||
transaction,
|
||||
subscription_period,
|
||||
was_trial_conversion=False,
|
||||
|
||||
@@ -68,17 +68,13 @@ 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(
|
||||
@@ -89,72 +85,108 @@ async def send_poll_to_users(
|
||||
for user in users
|
||||
]
|
||||
|
||||
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,
|
||||
)
|
||||
# Получаем список пользователей, которые уже прошли опрос, за один запрос
|
||||
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)
|
||||
)
|
||||
)
|
||||
if existing_response.scalar_one_or_none():
|
||||
skipped += 1
|
||||
continue
|
||||
)
|
||||
existing_user_ids = set(existing_responses_result.scalars().all())
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(response)
|
||||
# Используем умеренный семафор, чтобы не превышать лимиты подключений к БД
|
||||
semaphore = asyncio.Semaphore(30) # Баланс между производительностью и нагрузкой на БД
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
# Создаем отдельную функцию для создания отдельной сессии для каждой отправки
|
||||
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"
|
||||
|
||||
text = _build_poll_invitation_text(poll_snapshot, user.language)
|
||||
keyboard = build_start_keyboard(response.id, user.language)
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user_snapshot.id,
|
||||
)
|
||||
new_db.add(response)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await new_db.flush()
|
||||
|
||||
await db.commit()
|
||||
sent += 1
|
||||
text = _build_poll_invitation_text(poll, user_snapshot.language)
|
||||
keyboard = build_start_keyboard(response.id, user_snapshot.language)
|
||||
|
||||
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
|
||||
logger.info(
|
||||
"ℹ️ Пропуск пользователя %s при отправке опроса %s: %s",
|
||||
user.telegram_id,
|
||||
poll_id,
|
||||
error,
|
||||
)
|
||||
else: # pragma: no cover - unexpected telegram error
|
||||
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
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
elif result == "skipped":
|
||||
skipped += 1
|
||||
elif isinstance(result, Exception): # Ошибка выполнения задачи
|
||||
failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
|
||||
return {
|
||||
"sent": sent,
|
||||
|
||||
@@ -92,18 +92,19 @@ 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
|
||||
|
||||
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)
|
||||
# Получаем текущую primary промогруппу
|
||||
old_group = user.get_primary_promo_group()
|
||||
|
||||
total_spent = await get_user_total_spent_kopeks(db, user_id)
|
||||
if total_spent <= 0:
|
||||
@@ -120,7 +121,6 @@ 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,12 +133,16 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
)
|
||||
return None
|
||||
|
||||
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
|
||||
# Проверяем, есть ли уже эта группа у пользователя
|
||||
already_has_group = await has_user_promo_group(db, user_id, target_group.id)
|
||||
|
||||
if user.auto_promo_group_assigned and already_has_group:
|
||||
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()
|
||||
@@ -150,18 +154,18 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
user.promo_group_id = target_group.id
|
||||
user.promo_group = target_group
|
||||
if not already_has_group:
|
||||
# Добавляем новую промогруппу к существующим
|
||||
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by="auto")
|
||||
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,
|
||||
)
|
||||
@@ -169,7 +173,7 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
if not already_has_group:
|
||||
await _notify_admins_about_auto_assignment(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -9,6 +9,10 @@ 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
|
||||
@@ -56,6 +60,47 @@ 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
|
||||
@@ -71,6 +116,7 @@ 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 {
|
||||
@@ -131,12 +177,20 @@ class PromoCodeService:
|
||||
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=1,
|
||||
device_limit=device_limit,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -155,10 +209,15 @@ 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
|
||||
duration_days=trial_days,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
@@ -89,16 +89,64 @@ 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()
|
||||
|
||||
@@ -161,36 +209,33 @@ async def process_referral_topup(
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
else:
|
||||
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
|
||||
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"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
|
||||
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)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
+749
-157
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ 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__)
|
||||
|
||||
@@ -195,11 +196,16 @@ async def _prepare_auto_extend_context(
|
||||
|
||||
|
||||
def _apply_extension_updates(context: AutoExtendContext) -> None:
|
||||
"""
|
||||
Применяет обновления лимитов подписки (трафик, устройства, серверы).
|
||||
НЕ изменяет is_trial - это делается позже после успешного коммита продления.
|
||||
"""
|
||||
subscription = context.subscription
|
||||
|
||||
# Обновляем лимиты для триальной подписки
|
||||
if subscription.is_trial:
|
||||
subscription.is_trial = False
|
||||
subscription.status = "active"
|
||||
# НЕ удаляем триал здесь! Это будет сделано после успешного extend_subscription()
|
||||
# subscription.is_trial = False # УДАЛЕНО: преждевременное удаление триала
|
||||
if context.traffic_limit_gb is not None:
|
||||
subscription.traffic_limit_gb = context.traffic_limit_gb
|
||||
if context.device_limit is not None:
|
||||
@@ -207,6 +213,7 @@ 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 (
|
||||
@@ -274,6 +281,7 @@ async def _auto_extend_subscription(
|
||||
|
||||
subscription = prepared.subscription
|
||||
old_end_date = subscription.end_date
|
||||
was_trial = subscription.is_trial # Запоминаем, была ли подписка триальной
|
||||
|
||||
_apply_extension_updates(prepared)
|
||||
|
||||
@@ -283,6 +291,19 @@ 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",
|
||||
@@ -290,6 +311,8 @@ async def _auto_extend_subscription(
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
# НОВОЕ: Откатываем изменения при ошибке
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
transaction = None
|
||||
@@ -333,7 +356,7 @@ async def _auto_extend_subscription(
|
||||
getattr(user, "language", "ru"),
|
||||
)
|
||||
new_end_date = updated_subscription.end_date
|
||||
end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M")
|
||||
end_date_label = format_local_datetime(new_end_date, "%d.%m.%Y %H:%M")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
|
||||
@@ -17,6 +17,9 @@ 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__)
|
||||
|
||||
@@ -47,7 +50,7 @@ def _resolve_addon_discount_percent(
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
group = promo_group or (getattr(user, "promo_group", None) if user else None)
|
||||
group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
|
||||
return 0
|
||||
@@ -172,6 +175,7 @@ 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}")
|
||||
@@ -183,20 +187,24 @@ class SubscriptionService:
|
||||
except Exception as hwid_error:
|
||||
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
|
||||
|
||||
updated_user = await api.update_user(
|
||||
update_kwargs = dict(
|
||||
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(
|
||||
@@ -208,23 +216,31 @@ class SubscriptionService:
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = f"user_{user.telegram_id}"
|
||||
updated_user = await api.create_user(
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
create_kwargs = dict(
|
||||
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,
|
||||
@@ -282,20 +298,26 @@ class SubscriptionService:
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
updated_user = await api.update_user(
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
update_kwargs = dict(
|
||||
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(
|
||||
@@ -448,7 +470,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.promo_group if user else None)
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
@@ -548,7 +570,7 @@ class SubscriptionService:
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
@@ -565,7 +587,18 @@ class SubscriptionService:
|
||||
servers_discount = servers_price * servers_discount_percent // 100
|
||||
discounted_servers_price = servers_price - servers_discount
|
||||
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
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_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
@@ -617,7 +650,7 @@ class SubscriptionService:
|
||||
)
|
||||
logger.info(message)
|
||||
if devices_price > 0:
|
||||
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽"
|
||||
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||
@@ -765,7 +798,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.promo_group if user else None)
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
@@ -877,7 +910,7 @@ class SubscriptionService:
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
@@ -894,7 +927,18 @@ class SubscriptionService:
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
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)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
|
||||
@@ -9,7 +9,13 @@ 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
|
||||
from app.config import (
|
||||
Settings,
|
||||
settings,
|
||||
refresh_period_prices,
|
||||
refresh_traffic_prices,
|
||||
ENV_OVERRIDE_KEYS,
|
||||
)
|
||||
from app.database.crud.system_setting import (
|
||||
delete_system_setting,
|
||||
upsert_system_setting,
|
||||
@@ -71,6 +77,7 @@ class BotConfigurationService:
|
||||
"SUPPORT": "💬 Поддержка и тикеты",
|
||||
"LOCALIZATION": "🌍 Языки интерфейса",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"TIMEZONE": "🗂 Timezone",
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"PAYMENT_VERIFICATION": "🕵️ Проверка платежей",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
@@ -124,6 +131,7 @@ class BotConfigurationService:
|
||||
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
|
||||
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
|
||||
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
|
||||
"TIMEZONE": "Часовой пояс панели и отображение времени.",
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
@@ -195,6 +203,8 @@ 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",
|
||||
@@ -211,7 +221,6 @@ 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",
|
||||
@@ -261,6 +270,7 @@ 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",
|
||||
@@ -268,6 +278,7 @@ 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",
|
||||
}
|
||||
@@ -450,6 +461,21 @@ 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": "Булево значение.",
|
||||
@@ -499,6 +525,28 @@ 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": (
|
||||
"Список слов и фрагментов, при наличии которых в отображаемом имени "
|
||||
@@ -536,6 +584,31 @@ 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 бота и его токена и доступно только для чтения.",
|
||||
@@ -566,6 +639,10 @@ 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):
|
||||
@@ -679,6 +756,7 @@ 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]] = {}
|
||||
@@ -802,6 +880,8 @@ 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
|
||||
@@ -1097,6 +1177,12 @@ 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:
|
||||
@@ -1216,8 +1302,15 @@ class BotConfigurationService:
|
||||
|
||||
raw_value = cls.serialize_value(key, value)
|
||||
await upsert_system_setting(db, key, raw_value)
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, 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)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
@@ -1235,14 +1328,26 @@ class BotConfigurationService:
|
||||
|
||||
await delete_system_setting(db, key)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
if cls._is_env_override(key):
|
||||
logger.info(
|
||||
"Настройка %s сброшена в БД, используется значение из окружения",
|
||||
key,
|
||||
)
|
||||
else:
|
||||
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 {
|
||||
|
||||
@@ -131,7 +131,7 @@ class TributeService:
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
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 "🔄 Пополнение"
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user,
|
||||
get_users_spending_stats
|
||||
get_users_spending_stats, get_referrals
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
@@ -21,7 +21,7 @@ from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
|
||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||
TransactionType
|
||||
)
|
||||
@@ -411,6 +411,72 @@ class UserService:
|
||||
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
|
||||
return False, None, None, None
|
||||
|
||||
async def update_user_referrals(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
referral_user_ids: List[int],
|
||||
admin_id: int,
|
||||
) -> Tuple[bool, Dict[str, int]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, {"error": "user_not_found"}
|
||||
|
||||
unique_ids: List[int] = []
|
||||
for referral_id in referral_user_ids:
|
||||
if referral_id == user_id:
|
||||
continue
|
||||
if referral_id not in unique_ids:
|
||||
unique_ids.append(referral_id)
|
||||
|
||||
current_referrals = await get_referrals(db, user_id)
|
||||
current_ids = {ref.id for ref in current_referrals}
|
||||
|
||||
to_assign = unique_ids
|
||||
to_remove = [rid for rid in current_ids if rid not in unique_ids]
|
||||
to_add = [rid for rid in unique_ids if rid not in current_ids]
|
||||
|
||||
if to_assign:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(to_assign))
|
||||
.values(referred_by_id=user_id)
|
||||
)
|
||||
|
||||
if to_remove:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(to_remove))
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s",
|
||||
admin_id,
|
||||
user_id,
|
||||
len(to_add),
|
||||
len(to_remove),
|
||||
len(unique_ids),
|
||||
)
|
||||
|
||||
return True, {
|
||||
"added": len(to_add),
|
||||
"removed": len(to_remove),
|
||||
"total": len(unique_ids),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"Ошибка обновления рефералов пользователя %s: %s",
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return False, {"error": "update_failed"}
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -713,6 +779,29 @@ class UserService:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}")
|
||||
|
||||
try:
|
||||
heleket_result = await db.execute(
|
||||
select(HeleketPayment).where(HeleketPayment.user_id == user_id)
|
||||
)
|
||||
heleket_payments = heleket_result.scalars().all()
|
||||
|
||||
if heleket_payments:
|
||||
logger.info(
|
||||
f"🔄 Удаляем {len(heleket_payments)} Heleket платежей"
|
||||
)
|
||||
await db.execute(
|
||||
update(HeleketPayment)
|
||||
.where(HeleketPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(HeleketPayment).where(HeleketPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Heleket платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
|
||||
@@ -48,6 +48,7 @@ class AdminStates(StatesGroup):
|
||||
setting_promocode_value = State()
|
||||
setting_promocode_uses = State()
|
||||
setting_promocode_expiry = State()
|
||||
selecting_promo_group = State()
|
||||
|
||||
creating_campaign_name = State()
|
||||
creating_campaign_start = State()
|
||||
@@ -71,6 +72,7 @@ class AdminStates(StatesGroup):
|
||||
confirming_broadcast = State()
|
||||
|
||||
creating_promo_group_name = State()
|
||||
creating_promo_group_priority = State()
|
||||
creating_promo_group_traffic_discount = State()
|
||||
creating_promo_group_server_discount = State()
|
||||
creating_promo_group_device_discount = State()
|
||||
@@ -79,6 +81,7 @@ class AdminStates(StatesGroup):
|
||||
|
||||
editing_promo_group_menu = State()
|
||||
editing_promo_group_name = State()
|
||||
editing_promo_group_priority = State()
|
||||
editing_promo_group_traffic_discount = State()
|
||||
editing_promo_group_server_discount = State()
|
||||
editing_promo_group_device_discount = State()
|
||||
@@ -90,6 +93,7 @@ class AdminStates(StatesGroup):
|
||||
editing_device_price = State()
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
editing_user_referrals = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
editing_privacy_policy = State()
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Unified price display system for all subscription and balance pricing.
|
||||
|
||||
This module provides a centralized way to:
|
||||
- Calculate prices with all applicable discounts (promo groups, promo offers)
|
||||
- Format price buttons consistently across all flows
|
||||
- Ensure uniform discount display throughout the application
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from app.database.models import User
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PriceInfo:
|
||||
"""Container for pricing information with discounts."""
|
||||
|
||||
base_price: int # Original price without any discounts (kopeks)
|
||||
final_price: int # Final price after all discounts (kopeks)
|
||||
discount_percent: int # Total discount percentage
|
||||
|
||||
@property
|
||||
def has_discount(self) -> bool:
|
||||
"""Check if there's any discount applied."""
|
||||
return self.base_price > self.final_price and self.discount_percent > 0
|
||||
|
||||
@property
|
||||
def discount_value(self) -> int:
|
||||
"""Get the absolute discount value in kopeks."""
|
||||
return self.base_price - self.final_price
|
||||
|
||||
|
||||
def calculate_user_price(
|
||||
user: Optional[User],
|
||||
base_price: int,
|
||||
period_days: int,
|
||||
category: str = "period"
|
||||
) -> PriceInfo:
|
||||
"""
|
||||
Calculate final price for a user with all applicable discounts.
|
||||
|
||||
Args:
|
||||
user: User object (None for base/default pricing from settings)
|
||||
base_price: Base price without discounts (kopeks)
|
||||
period_days: Subscription period in days
|
||||
category: Discount category ("period", "servers", "devices", "traffic")
|
||||
|
||||
Returns:
|
||||
PriceInfo with base_price, final_price, and discount_percent
|
||||
|
||||
Example:
|
||||
>>> user = get_user_from_db(123)
|
||||
>>> price_info = calculate_user_price(user, 100000, 30, "period")
|
||||
>>> print(f"{price_info.base_price} -> {price_info.final_price} ({price_info.discount_percent}%)")
|
||||
100000 -> 80000 (20%)
|
||||
|
||||
>>> # For base pricing (no user)
|
||||
>>> price_info = calculate_user_price(None, 100000, 30, "period")
|
||||
>>> # Uses BASE_PROMO_GROUP_PERIOD_DISCOUNTS from settings
|
||||
"""
|
||||
if not base_price or base_price <= 0:
|
||||
return PriceInfo(base_price=base_price or 0, final_price=base_price or 0, discount_percent=0)
|
||||
|
||||
# Get discount percentage
|
||||
if user:
|
||||
# Get user's promo group discount for this category
|
||||
discount_percent = user.get_promo_discount(category, period_days)
|
||||
else:
|
||||
# For None user, use base settings discount (only for period category)
|
||||
if category == "period":
|
||||
discount_percent = settings.get_base_promo_group_period_discount(period_days)
|
||||
else:
|
||||
discount_percent = 0
|
||||
|
||||
logger.debug(
|
||||
f"calculate_user_price: user={user.telegram_id if user else 'None'}, "
|
||||
f"base_price={base_price}, period_days={period_days}, category={category}, "
|
||||
f"discount_percent={discount_percent}"
|
||||
)
|
||||
|
||||
if discount_percent <= 0:
|
||||
return PriceInfo(base_price=base_price, final_price=base_price, discount_percent=0)
|
||||
|
||||
# Calculate discounted price
|
||||
discount_value = (base_price * discount_percent) // 100
|
||||
final_price = base_price - discount_value
|
||||
|
||||
logger.debug(
|
||||
f"Calculated price for user {user.telegram_id if user else 'None'}: "
|
||||
f"{base_price} -> {final_price} (-{discount_percent}%) "
|
||||
f"[category={category}, period={period_days}]"
|
||||
)
|
||||
|
||||
return PriceInfo(
|
||||
base_price=base_price,
|
||||
final_price=final_price,
|
||||
discount_percent=discount_percent
|
||||
)
|
||||
|
||||
|
||||
def format_price_button(
|
||||
period_label: str,
|
||||
price_info: PriceInfo,
|
||||
format_price_func,
|
||||
emphasize: bool = False,
|
||||
add_exclamation: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Format a price button text with unified discount display.
|
||||
|
||||
Args:
|
||||
period_label: Label for the period (e.g., "30 дней", "1 месяц")
|
||||
price_info: PriceInfo object with pricing details
|
||||
format_price_func: Function to format price (usually texts.format_price)
|
||||
emphasize: Add fire emojis for emphasis (for best deals)
|
||||
add_exclamation: Add exclamation mark after discount percent
|
||||
|
||||
Returns:
|
||||
Formatted button text
|
||||
|
||||
Examples:
|
||||
With discount and price > 0:
|
||||
"📅 30 дней - 990₽ ➜ 693₽ (-30%)!"
|
||||
|
||||
With final price = 0:
|
||||
"📅 30 дней"
|
||||
|
||||
With emphasis:
|
||||
"🔥 📅 30 дней - 8990₽ ➜ 6293₽ (-30%)! 🔥"
|
||||
|
||||
Without discount:
|
||||
"📅 30 дней - 990₽"
|
||||
"""
|
||||
# Format button text differently if final price is 0
|
||||
if price_info.final_price == 0:
|
||||
button_text = f"📅 {period_label}"
|
||||
elif price_info.has_discount:
|
||||
exclamation = "!" if add_exclamation else ""
|
||||
button_text = (
|
||||
f"📅 {period_label} - "
|
||||
f"{format_price_func(price_info.base_price)} ➜ "
|
||||
f"{format_price_func(price_info.final_price)} "
|
||||
f"(-{price_info.discount_percent}%){exclamation}"
|
||||
)
|
||||
else:
|
||||
button_text = f"📅 {period_label} - {format_price_func(price_info.final_price)}"
|
||||
|
||||
# Add emphasis for best deals
|
||||
if emphasize:
|
||||
button_text = f"🔥 {button_text} 🔥"
|
||||
|
||||
logger.debug(f"Formatted button: {button_text}")
|
||||
return button_text
|
||||
|
||||
|
||||
def format_price_text(
|
||||
period_label: str,
|
||||
price_info: PriceInfo,
|
||||
format_price_func
|
||||
) -> str:
|
||||
"""
|
||||
Format a price for message text (not button) with unified discount display.
|
||||
|
||||
Args:
|
||||
period_label: Label for the period (e.g., "30 дней")
|
||||
price_info: PriceInfo object with pricing details
|
||||
format_price_func: Function to format price (usually texts.format_price)
|
||||
|
||||
Returns:
|
||||
Formatted price text for messages
|
||||
|
||||
Examples:
|
||||
With discount:
|
||||
"📅 30 дней - 990₽ ➜ 693₽"
|
||||
|
||||
Without discount:
|
||||
"📅 30 дней - 990₽"
|
||||
|
||||
With zero price:
|
||||
"📅 30 дней"
|
||||
"""
|
||||
if price_info.final_price == 0:
|
||||
return f"📅 {period_label}"
|
||||
elif price_info.has_discount:
|
||||
return (
|
||||
f"📅 {period_label} - "
|
||||
f"{format_price_func(price_info.base_price)} ➜ "
|
||||
f"{format_price_func(price_info.final_price)}"
|
||||
)
|
||||
else:
|
||||
return f"📅 {period_label} - {format_price_func(price_info.final_price)}"
|
||||
@@ -139,7 +139,7 @@ async def compute_simple_subscription_price(
|
||||
promo_group = await get_promo_group_by_id(db, int(promo_group_id))
|
||||
|
||||
if promo_group is None and user is not None:
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
|
||||
period_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
|
||||
@@ -174,3 +174,58 @@ def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -
|
||||
return subscription_link
|
||||
|
||||
return urlunparse(parsed_link._replace(scheme="happ"))
|
||||
|
||||
|
||||
def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[int]:
|
||||
"""Return a device limit value for RemnaWave payloads when selection is enabled."""
|
||||
|
||||
if subscription is None:
|
||||
return None
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
return forced_limit
|
||||
|
||||
limit = getattr(subscription, "device_limit", None)
|
||||
if limit is None or limit <= 0:
|
||||
return None
|
||||
|
||||
return limit
|
||||
|
||||
|
||||
def resolve_hwid_device_limit_for_payload(
|
||||
subscription: Optional[Subscription],
|
||||
) -> Optional[int]:
|
||||
"""Return the device limit that should be sent to RemnaWave APIs.
|
||||
|
||||
When device selection is disabled and no explicit override is configured,
|
||||
RemnaWave should continue receiving the subscription's stored limit so the
|
||||
external panel stays aligned with the bot configuration.
|
||||
"""
|
||||
|
||||
resolved_limit = resolve_hwid_device_limit(subscription)
|
||||
|
||||
if resolved_limit is not None:
|
||||
return resolved_limit
|
||||
|
||||
if subscription is None:
|
||||
return None
|
||||
|
||||
fallback_limit = getattr(subscription, "device_limit", None)
|
||||
if fallback_limit is None or fallback_limit <= 0:
|
||||
return None
|
||||
|
||||
return fallback_limit
|
||||
|
||||
|
||||
def resolve_simple_subscription_device_limit() -> int:
|
||||
"""Return the effective device limit for simple subscription flows."""
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0)
|
||||
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is not None:
|
||||
return forced_limit
|
||||
|
||||
return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Timezone utilities for consistent local time handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_local_timezone() -> ZoneInfo:
|
||||
"""Return the configured local timezone.
|
||||
|
||||
Falls back to UTC if the configured timezone cannot be loaded. The
|
||||
fallback is logged once and cached for subsequent calls.
|
||||
"""
|
||||
|
||||
tz_name = settings.TIMEZONE
|
||||
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.",
|
||||
tz_name,
|
||||
exc,
|
||||
)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def to_local_datetime(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
"""Convert a datetime value to the configured local timezone."""
|
||||
|
||||
if dt is None:
|
||||
return None
|
||||
|
||||
aware_dt = dt if dt.tzinfo is not None else dt.replace(tzinfo=dt_timezone.utc)
|
||||
return aware_dt.astimezone(get_local_timezone())
|
||||
|
||||
|
||||
def format_local_datetime(
|
||||
dt: Optional[datetime],
|
||||
fmt: str = "%Y-%m-%d %H:%M:%S %Z",
|
||||
na_placeholder: str = "N/A",
|
||||
) -> str:
|
||||
"""Format a datetime value in the configured local timezone."""
|
||||
|
||||
localized = to_local_datetime(dt)
|
||||
if localized is None:
|
||||
return na_placeholder
|
||||
return localized.strftime(fmt)
|
||||
|
||||
|
||||
class TimezoneAwareFormatter(logging.Formatter):
|
||||
"""Logging formatter that renders timestamps in the configured timezone."""
|
||||
|
||||
def __init__(self, *args, timezone_name: Optional[str] = None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if timezone_name:
|
||||
try:
|
||||
self._timezone = ZoneInfo(timezone_name)
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.",
|
||||
timezone_name,
|
||||
exc,
|
||||
)
|
||||
self._timezone = ZoneInfo("UTC")
|
||||
else:
|
||||
self._timezone = get_local_timezone()
|
||||
|
||||
def formatTime(self, record, datefmt=None): # noqa: N802 - inherited method name
|
||||
dt = datetime.fromtimestamp(record.created, tz=self._timezone)
|
||||
if datefmt:
|
||||
return dt.strftime(datefmt)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S,%f")[:-3]
|
||||
+20
-9
@@ -20,15 +20,26 @@ def format_referrer_info(user: User) -> str:
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
referrer = getattr(user, "referrer", None)
|
||||
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
|
||||
return f"ID {referrer.telegram_id}"
|
||||
try:
|
||||
# Проверяем, является ли referrer обычным объектом или InstrumentedList
|
||||
referrer = getattr(user, "referrer", None)
|
||||
|
||||
# Если referrer это InstrumentedList или None, то возвращаем информацию по ID
|
||||
if referrer is None:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
# Пытаемся получить атрибуты referrer, если они доступны
|
||||
referrer_username = getattr(referrer, "username", None)
|
||||
referrer_telegram_id = getattr(referrer, "telegram_id", None)
|
||||
|
||||
if referrer_username:
|
||||
return f"@{referrer_username} (ID: {referred_by_id})"
|
||||
|
||||
return f"ID {referrer_telegram_id or referred_by_id}"
|
||||
|
||||
except (AttributeError, TypeError):
|
||||
# Если возникла ошибка при обращении к атрибутам, просто возвращаем ID
|
||||
return f"ID {referred_by_id} (ошибка загрузки)"
|
||||
|
||||
|
||||
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
|
||||
|
||||
+10
-1
@@ -20,6 +20,7 @@ from .routes import (
|
||||
promo_offers,
|
||||
pages,
|
||||
remnawave,
|
||||
servers,
|
||||
stats,
|
||||
subscriptions,
|
||||
tickets,
|
||||
@@ -67,6 +68,13 @@ OPENAPI_TAGS = [
|
||||
"name": "promo-groups",
|
||||
"description": "Создание и управление промо-группами и их участниками.",
|
||||
},
|
||||
{
|
||||
"name": "servers",
|
||||
"description": (
|
||||
"Управление серверами RemnaWave, их доступностью, промогруппами и "
|
||||
"ручная синхронизация данных.",
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "promo-offers",
|
||||
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
|
||||
@@ -106,13 +114,13 @@ OPENAPI_TAGS = [
|
||||
def create_web_api_app() -> FastAPI:
|
||||
docs_config = settings.get_web_api_docs_config()
|
||||
|
||||
# Убираем openapi_tags для предотвращения ошибок при генерации openapi.json
|
||||
app = FastAPI(
|
||||
title=settings.WEB_API_TITLE,
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
openapi_tags=OPENAPI_TAGS,
|
||||
swagger_ui_parameters={"persistAuthorization": True},
|
||||
)
|
||||
|
||||
@@ -137,6 +145,7 @@ def create_web_api_app() -> FastAPI:
|
||||
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
|
||||
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
|
||||
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
|
||||
app.include_router(servers.router, prefix="/servers", tags=["servers"])
|
||||
app.include_router(
|
||||
main_menu_buttons.router,
|
||||
prefix="/main-menu/buttons",
|
||||
|
||||
@@ -7,6 +7,7 @@ from . import (
|
||||
promo_offers,
|
||||
pages,
|
||||
promo_groups,
|
||||
servers,
|
||||
remnawave,
|
||||
stats,
|
||||
subscriptions,
|
||||
@@ -26,6 +27,7 @@ __all__ = [
|
||||
"promo_offers",
|
||||
"pages",
|
||||
"promo_groups",
|
||||
"servers",
|
||||
"remnawave",
|
||||
"stats",
|
||||
"subscriptions",
|
||||
|
||||
@@ -58,6 +58,7 @@ from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.services.remnawave_service import (
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
@@ -2408,23 +2409,6 @@ async def _build_referral_info(
|
||||
inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0)
|
||||
commission_percent = float(referral_settings.get("commission_percent") or 0)
|
||||
|
||||
referred_user_reward_kopeks = settings.get_referred_user_reward_kopeks()
|
||||
for key in ("referred_user_reward_kopeks", "referred_user_reward"):
|
||||
candidate = referral_settings.get(key)
|
||||
if candidate is None:
|
||||
continue
|
||||
try:
|
||||
value = int(candidate)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value <= 0:
|
||||
referred_user_reward_kopeks = 0
|
||||
break
|
||||
if key == "referred_user_reward" and value < 1000:
|
||||
value *= 100
|
||||
referred_user_reward_kopeks = value
|
||||
break
|
||||
|
||||
terms = MiniAppReferralTerms(
|
||||
minimum_topup_kopeks=minimum_topup_kopeks,
|
||||
minimum_topup_label=settings.format_price(minimum_topup_kopeks),
|
||||
@@ -2433,8 +2417,6 @@ async def _build_referral_info(
|
||||
inviter_bonus_kopeks=inviter_bonus_kopeks,
|
||||
inviter_bonus_label=settings.format_price(inviter_bonus_kopeks),
|
||||
commission_percent=commission_percent,
|
||||
referred_user_reward_kopeks=referred_user_reward_kopeks,
|
||||
referred_user_reward_label=settings.format_price(referred_user_reward_kopeks),
|
||||
)
|
||||
|
||||
summary = await get_user_referral_summary(db, user.id)
|
||||
@@ -3121,8 +3103,16 @@ async def activate_subscription_trial_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
try:
|
||||
subscription = await create_trial_subscription(db, user.id)
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to activate trial subscription for user %s: %s",
|
||||
@@ -3638,7 +3628,9 @@ async def _calculate_subscription_renewal_pricing(
|
||||
if traffic_limit is None:
|
||||
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
total_cost, details = await calculate_subscription_total_cost(
|
||||
db,
|
||||
@@ -4437,7 +4429,7 @@ async def submit_subscription_renewal_endpoint(
|
||||
language_code = _normalize_language_code(user)
|
||||
amount_label = settings.format_price(final_total)
|
||||
date_label = (
|
||||
subscription.end_date.strftime("%d.%m.%Y %H:%M")
|
||||
format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")
|
||||
if subscription.end_date
|
||||
else ""
|
||||
)
|
||||
@@ -5044,7 +5036,12 @@ async def update_subscription_devices_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
current_devices = int(subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT or 1)
|
||||
current_devices_value = subscription.device_limit
|
||||
if current_devices_value is None:
|
||||
fallback_value = settings.DEFAULT_DEVICE_LIMIT or 1
|
||||
current_devices_value = fallback_value
|
||||
|
||||
current_devices = int(current_devices_value)
|
||||
old_devices = current_devices
|
||||
|
||||
if new_devices == current_devices:
|
||||
|
||||
@@ -52,12 +52,12 @@ def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
|
||||
apply_discounts_to_addons=group.apply_discounts_to_addons,
|
||||
is_default=group.is_default,
|
||||
members_count=members_count,
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
created_at=getattr(group, "created_at", None),
|
||||
updated_at=getattr(group, "updated_at", None),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoGroupListResponse)
|
||||
@router.get("", response_model=PromoGroupListResponse, response_model_exclude_none=True)
|
||||
async def list_promo_groups(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
@@ -79,7 +79,7 @@ async def list_promo_groups(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse)
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse, response_model_exclude_none=True)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -93,7 +93,12 @@ async def get_promo_group(
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post(
|
||||
"",
|
||||
response_model=PromoGroupResponse,
|
||||
response_model_exclude_none=True,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -120,7 +125,11 @@ async def create_promo_group_endpoint(
|
||||
return _serialize(group, members_count=0)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=PromoGroupResponse)
|
||||
@router.patch(
|
||||
"/{group_id}",
|
||||
response_model=PromoGroupResponse,
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.database.crud.promocode import (
|
||||
create_promocode,
|
||||
delete_promocode,
|
||||
get_promocode_by_code,
|
||||
get_promocode_by_id,
|
||||
get_promocode_statistics,
|
||||
get_promocodes_count,
|
||||
get_promocodes_list,
|
||||
@@ -163,7 +164,7 @@ async def get_promocode(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoCodeDetailResponse:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
|
||||
|
||||
@@ -236,7 +237,7 @@ async def update_promocode_endpoint(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoCodeResponse:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
|
||||
|
||||
@@ -290,7 +291,7 @@ async def delete_promocode_endpoint(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Маршруты управления серверами в административном API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Iterable, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.server_squad import (
|
||||
create_server_squad,
|
||||
delete_server_squad,
|
||||
get_server_connected_users,
|
||||
get_server_squad_by_id,
|
||||
get_server_squad_by_uuid,
|
||||
get_server_statistics,
|
||||
sync_server_user_counts,
|
||||
sync_with_remnawave,
|
||||
update_server_squad,
|
||||
update_server_squad_promo_groups,
|
||||
)
|
||||
from app.database.models import PromoGroup, ServerSquad, User
|
||||
from app.utils.cache import cache
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.servers import (
|
||||
ServerConnectedUser,
|
||||
ServerConnectedUsersResponse,
|
||||
ServerCountsSyncResponse,
|
||||
ServerCreateRequest,
|
||||
ServerDeleteResponse,
|
||||
ServerListResponse,
|
||||
ServerResponse,
|
||||
ServerStatisticsResponse,
|
||||
ServerSyncResponse,
|
||||
ServerUpdateRequest,
|
||||
)
|
||||
from ..schemas.users import PromoGroupSummary
|
||||
|
||||
try: # pragma: no cover - импорт может провалиться без optional-зависимостей
|
||||
from app.services.remnawave_service import RemnaWaveService # type: ignore
|
||||
except Exception: # pragma: no cover - скрываем функционал, если сервис недоступен
|
||||
RemnaWaveService = None # type: ignore[assignment]
|
||||
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - только для подсказок типов в IDE
|
||||
from app.services.remnawave_service import ( # type: ignore
|
||||
RemnaWaveService as RemnaWaveServiceType,
|
||||
)
|
||||
else:
|
||||
RemnaWaveServiceType = Any
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_promo_group(group: PromoGroup) -> PromoGroupSummary:
|
||||
return PromoGroupSummary(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
server_discount_percent=group.server_discount_percent,
|
||||
traffic_discount_percent=group.traffic_discount_percent,
|
||||
device_discount_percent=group.device_discount_percent,
|
||||
apply_discounts_to_addons=getattr(group, "apply_discounts_to_addons", True),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_server(server: ServerSquad) -> ServerResponse:
|
||||
promo_groups = [
|
||||
_serialize_promo_group(group)
|
||||
for group in sorted(
|
||||
getattr(server, "allowed_promo_groups", []) or [],
|
||||
key=lambda pg: pg.name.lower() if getattr(pg, "name", None) else "",
|
||||
)
|
||||
]
|
||||
|
||||
return ServerResponse(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
original_name=server.original_name,
|
||||
country_code=server.country_code,
|
||||
is_available=bool(server.is_available),
|
||||
is_trial_eligible=bool(server.is_trial_eligible),
|
||||
price_kopeks=int(server.price_kopeks or 0),
|
||||
price_rubles=round((server.price_kopeks or 0) / 100, 2),
|
||||
description=server.description,
|
||||
sort_order=int(server.sort_order or 0),
|
||||
max_users=server.max_users,
|
||||
current_users=int(server.current_users or 0),
|
||||
created_at=getattr(server, "created_at", None),
|
||||
updated_at=getattr(server, "updated_at", None),
|
||||
promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_connected_user(user: User) -> ServerConnectedUser:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
subscription_status = getattr(subscription, "status", None)
|
||||
if hasattr(subscription_status, "value"):
|
||||
subscription_status = subscription_status.value
|
||||
|
||||
return ServerConnectedUser(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
status=getattr(getattr(user, "status", None), "value", user.status),
|
||||
balance_kopeks=int(user.balance_kopeks or 0),
|
||||
balance_rubles=round((user.balance_kopeks or 0) / 100, 2),
|
||||
subscription_id=getattr(subscription, "id", None),
|
||||
subscription_status=subscription_status,
|
||||
subscription_end_date=getattr(subscription, "end_date", None),
|
||||
)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
filters: Iterable[Any],
|
||||
query,
|
||||
):
|
||||
for condition in filters:
|
||||
query = query.where(condition)
|
||||
return query
|
||||
|
||||
|
||||
def _get_remnawave_service() -> "RemnaWaveServiceType":
|
||||
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="RemnaWave сервис недоступен",
|
||||
)
|
||||
|
||||
return RemnaWaveService()
|
||||
|
||||
|
||||
def _ensure_service_configured(service: "RemnaWaveServiceType") -> None:
|
||||
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="RemnaWave сервис недоступен",
|
||||
)
|
||||
|
||||
if not service.is_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=service.configuration_error or "RemnaWave API не настроен",
|
||||
)
|
||||
|
||||
|
||||
async def _validate_promo_group_ids(
|
||||
db: AsyncSession, promo_group_ids: Iterable[int]
|
||||
) -> List[int]:
|
||||
unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
|
||||
|
||||
if not unique_ids:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Нужно выбрать хотя бы одну промогруппу",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup.id).where(PromoGroup.id.in_(unique_ids))
|
||||
)
|
||||
found_ids = result.scalars().all()
|
||||
|
||||
if not found_ids:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Не найдены промогруппы для обновления сервера",
|
||||
)
|
||||
|
||||
return unique_ids
|
||||
|
||||
|
||||
@router.get("", response_model=ServerListResponse)
|
||||
async def list_servers(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
available_only: bool = Query(False, alias="available"),
|
||||
search: Optional[str] = Query(default=None),
|
||||
) -> ServerListResponse:
|
||||
filters = []
|
||||
|
||||
if available_only:
|
||||
filters.append(ServerSquad.is_available.is_(True))
|
||||
|
||||
if search:
|
||||
pattern = f"%{search.lower()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
func.lower(ServerSquad.display_name).like(pattern),
|
||||
func.lower(ServerSquad.original_name).like(pattern),
|
||||
func.lower(ServerSquad.squad_uuid).like(pattern),
|
||||
func.lower(ServerSquad.country_code).like(pattern),
|
||||
)
|
||||
)
|
||||
|
||||
base_query = (
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
|
||||
count_query = select(func.count(ServerSquad.id))
|
||||
|
||||
if filters:
|
||||
base_query = _apply_filters(filters, base_query)
|
||||
count_query = _apply_filters(filters, count_query)
|
||||
|
||||
total = await db.scalar(count_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.offset((page - 1) * limit).limit(limit)
|
||||
)
|
||||
servers = result.scalars().unique().all()
|
||||
|
||||
return ServerListResponse(
|
||||
items=[_serialize_server(server) for server in servers],
|
||||
total=int(total),
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ServerStatisticsResponse)
|
||||
async def get_servers_statistics(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServerStatisticsResponse:
|
||||
stats = await get_server_statistics(db)
|
||||
|
||||
return ServerStatisticsResponse(
|
||||
total_servers=int(stats.get("total_servers", 0) or 0),
|
||||
available_servers=int(stats.get("available_servers", 0) or 0),
|
||||
unavailable_servers=int(stats.get("unavailable_servers", 0) or 0),
|
||||
servers_with_connections=int(stats.get("servers_with_connections", 0) or 0),
|
||||
total_revenue_kopeks=int(stats.get("total_revenue_kopeks", 0) or 0),
|
||||
total_revenue_rubles=float(stats.get("total_revenue_rubles", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ServerResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_server_endpoint(
|
||||
payload: ServerCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServerResponse:
|
||||
existing = await get_server_squad_by_uuid(db, payload.squad_uuid)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Server with this UUID already exists",
|
||||
)
|
||||
|
||||
try:
|
||||
server = await create_server_squad(
|
||||
db,
|
||||
squad_uuid=payload.squad_uuid,
|
||||
display_name=payload.display_name,
|
||||
original_name=payload.original_name,
|
||||
country_code=payload.country_code,
|
||||
price_kopeks=payload.price_kopeks,
|
||||
description=payload.description,
|
||||
max_users=payload.max_users,
|
||||
is_available=payload.is_available,
|
||||
is_trial_eligible=payload.is_trial_eligible,
|
||||
sort_order=payload.sort_order,
|
||||
promo_group_ids=payload.promo_group_ids,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
server = await get_server_squad_by_id(db, server.id)
|
||||
assert server is not None
|
||||
return _serialize_server(server)
|
||||
|
||||
|
||||
@router.get("/{server_id}", response_model=ServerResponse)
|
||||
async def get_server_endpoint(
|
||||
server_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServerResponse:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Server not found")
|
||||
|
||||
return _serialize_server(server)
|
||||
|
||||
|
||||
@router.patch("/{server_id}", response_model=ServerResponse)
|
||||
async def update_server_endpoint(
|
||||
server_id: int,
|
||||
payload: ServerUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServerResponse:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Server not found")
|
||||
|
||||
updates = payload.model_dump(exclude_unset=True, by_alias=False)
|
||||
promo_group_ids = updates.pop("promo_group_ids", None)
|
||||
|
||||
validated_promo_group_ids: Optional[List[int]] = None
|
||||
if promo_group_ids is not None:
|
||||
validated_promo_group_ids = await _validate_promo_group_ids(
|
||||
db, promo_group_ids
|
||||
)
|
||||
|
||||
if updates:
|
||||
server = await update_server_squad(db, server_id, **updates) or server
|
||||
|
||||
if promo_group_ids is not None:
|
||||
try:
|
||||
assert validated_promo_group_ids is not None
|
||||
server = await update_server_squad_promo_groups(
|
||||
db, server_id, validated_promo_group_ids
|
||||
) or server
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
assert server is not None
|
||||
return _serialize_server(server)
|
||||
|
||||
|
||||
@router.delete("/{server_id}", response_model=ServerDeleteResponse)
|
||||
async def delete_server_endpoint(
|
||||
server_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServerDeleteResponse:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Server not found")
|
||||
|
||||
deleted = await delete_server_squad(db, server_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Server cannot be deleted because it has active connections",
|
||||
)
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
return ServerDeleteResponse(success=True, message="Server deleted")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{server_id}/users",
|
||||
response_model=ServerConnectedUsersResponse,
|
||||
)
|
||||
async def get_server_connected_users_endpoint(
|
||||
server_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> ServerConnectedUsersResponse:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Server not found")
|
||||
|
||||
users = await get_server_connected_users(db, server_id)
|
||||
total = len(users)
|
||||
sliced = users[offset : offset + limit]
|
||||
|
||||
return ServerConnectedUsersResponse(
|
||||
items=[_serialize_connected_user(user) for user in sliced],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=ServerSyncResponse)
|
||||
async def sync_servers_with_remnawave(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServerSyncResponse:
|
||||
service = _get_remnawave_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
squads = await service.get_all_squads()
|
||||
total = len(squads)
|
||||
|
||||
created = updated = removed = 0
|
||||
if squads:
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
return ServerSyncResponse(
|
||||
created=created,
|
||||
updated=updated,
|
||||
removed=removed,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync-counts", response_model=ServerCountsSyncResponse)
|
||||
async def sync_server_counts(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServerCountsSyncResponse:
|
||||
updated = await sync_server_user_counts(db)
|
||||
return ServerCountsSyncResponse(updated=updated)
|
||||
|
||||
@@ -112,24 +112,38 @@ async def create_subscription(
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
if payload.is_trial:
|
||||
trial_device_limit = payload.device_limit
|
||||
if trial_device_limit is None:
|
||||
trial_device_limit = forced_devices
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb,
|
||||
device_limit=payload.device_limit,
|
||||
device_limit=trial_device_limit,
|
||||
squad_uuid=payload.squad_uuid,
|
||||
)
|
||||
else:
|
||||
if payload.duration_days is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
||||
device_limit = payload.device_limit
|
||||
if device_limit is None:
|
||||
if forced_devices is not None:
|
||||
device_limit = forced_devices
|
||||
else:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
subscription = await create_paid_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
device_limit=device_limit,
|
||||
connected_squads=payload.connected_squads or [],
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
@@ -296,8 +296,6 @@ class MiniAppReferralTerms(BaseModel):
|
||||
inviter_bonus_kopeks: int = 0
|
||||
inviter_bonus_label: Optional[str] = None
|
||||
commission_percent: float = 0.0
|
||||
referred_user_reward_kopeks: int = 0
|
||||
referred_user_reward_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppReferralStats(BaseModel):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user