Merge branch 'main' into main

This commit is contained in:
Mikhail
2026-01-16 12:16:23 +03:00
committed by GitHub
96 changed files with 18924 additions and 1015 deletions
+11 -4
View File
@@ -181,10 +181,17 @@ REMNAWAVE_AUTO_SYNC_TIMES=03:00
# ========= ПОДПИСКИ =========
# ===== РЕЖИМ ПРОДАЖ =====
# Режим продаж подписок:
# "classic" - классический режим (выбор серверов, трафика, устройств, периода отдельно)
# "tariffs" - режим тарифов (готовые пакеты с фиксированными параметрами)
SALES_MODE=classic
# Режим продаж подписок (можно переключить в кабинете: Настройки → Подписки):
# "classic" - классический режим:
# - Пользователь выбирает период, серверы, трафик, устройства отдельно
# - Цены периодов берутся из PERIOD_PRICES ниже
# - Подходит для гибкой настройки под каждого пользователя
# "tariffs" - режим тарифов:
# - Пользователь выбирает готовый тариф (Premium, Basic и т.д.)
# - Тарифы создаются в кабинете: Админ → Тарифы
# - Каждый тариф имеет свои серверы, трафик, устройства и цены за периоды
# - Подходит для продажи готовых пакетов услуг
SALES_MODE=tariffs
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.5-$(git rev-parse --short HEAD)"
VERSION="v3.0.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.9.5-dev-$(git rev-parse --short HEAD)"
VERSION="v3.0.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.9.5-pr-$(git rev-parse --short HEAD)"
VERSION="v3.0.0-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.5-$(git rev-parse --short HEAD)"
VERSION="v3.0.0-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)"
VERSION="v3.0.0-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)"
VERSION="v3.0.0-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.9.5"
ARG VERSION="v3.0.0"
ARG BUILD_DATE
ARG VCS_REF
+120 -47
View File
@@ -6,7 +6,7 @@
**🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API**
*Полнофункциональное решение с управлением пользователями, платежами и администрированием*
_Полнофункциональное решение с управлением пользователями, платежами и администрированием_
[![Python](https://img.shields.io/badge/Python-3.13+-blue?logo=python&logoColor=white)](https://python.org)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-blue?logo=postgresql&logoColor=white)](https://postgresql.org)
@@ -34,6 +34,7 @@
Вы хотите спать — он позволит и это...
### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA + Freekassa (NSPK СБП + карты) + CloudPayments (карты + СБП)
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
@@ -42,6 +43,7 @@
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
### 🎛️ **Гибкость конфигурации**
- 🌐 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор
- 📱 **Управление устройствами** - от 1 до неограниченного количества
- 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов
@@ -51,6 +53,7 @@
- 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса
### 💪 **Enterprise готовность**
- 🗃️ **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура
- 🔒 **Безопасность** - интеграция с системой защиты панели через куки-аутентификацию
- 📈 **Масштабируемость** - от стартапа до крупного бизнеса
@@ -67,7 +70,7 @@
### 📚 Поддерживаемые методы авторизации
| Конфигурация | Authorization | X-Api-Key |
|------------------|---------------------|---------------------|
| ---------------- | ------------------- | ------------------- |
| Только API Key | Bearer <api_key> | <api_key> |
| Basic Auth | Bearer <api_key> | Basic <user:pass> |
| Caddy + API Key | Basic <caddy_token> | <api_key> |
@@ -136,7 +139,7 @@ docker compose logs
### 1. Выбор режима запуска
| `BOT_RUN_MODE` | Что делает | Когда использовать |
|----------------|------------|---------------------|
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
@@ -168,6 +171,7 @@ REMNAWAVE_API_KEY=your_api_key
```
**Важно:**
- `WEBHOOK_SECRET_TOKEN` - сгенерируйте командой `openssl rand -hex 32`
- `WEB_API_DEFAULT_TOKEN` - сгенерируйте командой `openssl rand -hex 32`
- `WEBHOOK_URL` должен быть доступен извне по HTTPS
@@ -192,10 +196,10 @@ WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com
```
* `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`.
- `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`.
@@ -206,6 +210,7 @@ WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com
Если бот запускается на отдельном сервере (не там, где панель Remnawave), используйте стандартную конфигурацию:
**docker-compose.yml бота:**
```yaml
services:
postgres:
@@ -216,13 +221,17 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C'
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}',
]
interval: 30s
timeout: 5s
retries: 5
@@ -238,7 +247,7 @@ services:
networks:
- bot_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ['CMD', 'redis-cli', 'ping']
interval: 30s
timeout: 10s
retries: 3
@@ -255,16 +264,16 @@ services:
env_file:
- .env
environment:
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
DOCKER_ENV: 'true'
DATABASE_MODE: 'auto'
POSTGRES_HOST: 'postgres'
POSTGRES_PORT: '5432'
POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}'
POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}'
REDIS_URL: 'redis://redis:6379/0'
TZ: 'Europe/Moscow'
LOCALES_PATH: '${LOCALES_PATH:-/app/locales}'
volumes:
- ./logs:/app/logs:rw
- ./data:/app/data:rw
@@ -273,11 +282,15 @@ services:
- /etc/localtime:/etc/localtime:ro
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- '${WEB_API_PORT:-8080}:8080'
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
test:
[
'CMD-SHELL',
'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"',
]
interval: 60s
timeout: 10s
retries: 3
@@ -297,9 +310,12 @@ networks:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
driver_opts:
com.docker.network.driver.mtu: 1350
```
**docker-compose.yml для Caddy (отдельный файл):**
```yaml
services:
caddy:
@@ -307,8 +323,8 @@ services:
container_name: remnawave_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- '80:80'
- '443:443'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
@@ -331,6 +347,7 @@ networks:
Если бот и панель Remnawave запускаются на одном сервере, подключите бота к сети панели:
**docker-compose.local.yml бота(есть в репо):**
```yaml
services:
postgres:
@@ -341,14 +358,18 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C'
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}',
]
interval: 30s
timeout: 5s
retries: 5
@@ -365,7 +386,7 @@ services:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ['CMD', 'redis-cli', 'ping']
interval: 30s
timeout: 10s
retries: 3
@@ -382,16 +403,16 @@ services:
env_file:
- .env
environment:
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
DOCKER_ENV: 'true'
DATABASE_MODE: 'auto'
POSTGRES_HOST: 'postgres'
POSTGRES_PORT: '5432'
POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}'
POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}'
REDIS_URL: 'redis://redis:6379/0'
TZ: 'Europe/Moscow'
LOCALES_PATH: '${LOCALES_PATH:-/app/locales}'
volumes:
- ./logs:/app/logs:rw
- ./data:/app/data:rw
@@ -400,12 +421,16 @@ services:
- /etc/localtime:/etc/localtime:ro
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- '${WEB_API_PORT:-8080}:8080'
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
test:
[
'CMD-SHELL',
'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"',
]
interval: 60s
timeout: 10s
retries: 3
@@ -424,6 +449,8 @@ networks:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
driver_opts:
com.docker.network.driver.mtu: 1350
remnawave-network:
name: remnawave-network
@@ -431,6 +458,7 @@ networks:
```
**Важно:**
- Сеть `remnawave-network` должна быть создана панелью Remnawave
- В `.env` укажите `REMNAWAVE_API_URL=http://remnawave:3000` (обращение внутри Docker-сети)
- Бот сможет напрямую общаться с панелью без внешних запросов
@@ -855,11 +883,13 @@ http {
### Быстрая настройка SSL для Nginx в Docker
#### 1. Установка Certbot
```bash
sudo apt update && sudo apt install certbot -y
```
#### 2. Генерация сертификатов
```bash
# Остановите Nginx Docker контейнер
docker compose down
@@ -870,6 +900,7 @@ sudo certbot certonly --standalone -d miniapp.domain.com --agree-tos --email you
```
#### 3. Копирование сертификатов
```bash
sudo mkdir -p /etc/ssl/private
@@ -882,14 +913,15 @@ sudo chmod 600 /etc/ssl/private/*.pem
```
#### 4. Обновите docker-compose.yml
```yaml
services:
nginx:
image: nginx:latest
container_name: nginx
ports:
- "80:80"
- "443:443"
- '80:80'
- '443:443'
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/ssl/private:/etc/ssl/private:ro
@@ -904,11 +936,13 @@ networks:
```
#### 5. Запуск Nginx
```bash
docker compose up -d
```
#### 6. Автообновление сертификатов
```bash
# Создайте скрипт обновления
sudo tee /opt/renew-certs.sh > /dev/null <<'EOF'
@@ -938,12 +972,14 @@ echo "0 3 * * * /opt/renew-certs.sh" | sudo crontab -
```
#### Проверка работы SSL
```bash
curl -I https://hooks.domain.com
curl -I https://miniapp.domain.com
```
**Важно:**
- Замените `domain.com` и `your-email@example.com` на свои данные
- Замените `/путь/к/docker-compose.yml` на реальный путь к вашему docker-compose файлу
- Убедитесь, что DNS записи для доменов указывают на IP вашего сервера
@@ -955,10 +991,10 @@ curl -I https://miniapp.domain.com
### 🔧 Основные параметры
| Настройка | Где взять | Пример |
|-----------|-----------|---------|
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
[Полный список доступных параметров:](.env.example)
@@ -985,7 +1021,7 @@ curl -I https://miniapp.domain.com
### 📊 Статус серверов в главном меню
| Переменная | Описание | Пример |
|------------|----------|--------|
| ---------------------------- | ------------------------------------------------------------------------------------ | --------------------------------- |
| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link`, `external_link_miniapp` или `xray` | `xray` |
| `SERVER_STATUS_EXTERNAL_URL` | Прямаяссылка на внешний мониторинг | `https://status.example.com` |
| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker | `https://sub.example.com/metrics` |
@@ -1017,6 +1053,7 @@ FREEKASSA_PAYMENT_SYSTEM_ID=44 # 44 = NSPK СБП, 42 = обычный СБП
```
**Важно:**
- `FREEKASSA_USE_API=true` - использовать API для создания заказов (рекомендуется для NSPK)
- `FREEKASSA_USE_API=false` - редирект на платежную форму
- `FREEKASSA_PAYMENT_SYSTEM_ID=44` - для NSPK СБП (быстрее и надежнее)
@@ -1034,6 +1071,7 @@ CLOUDPAYMENTS_CURRENCY=RUB
```
**Важно:**
- `CLOUDPAYMENTS_PUBLIC_ID` - Public ID из личного кабинета CloudPayments
- `CLOUDPAYMENTS_API_SECRET` - API Secret для подписи запросов
- Настройте webhook (Pay/Fail уведомления) на `https://your-domain.com/cloudpayments-webhook`
@@ -1068,12 +1106,14 @@ PLATEGA_WEBHOOK_PATH=/platega-webhook
### 📊 Режимы продажи трафика
#### **Выбираемые пакеты** (по умолчанию)
```env
TRAFFIC_SELECTION_MODE=selectable
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
```
#### **Фиксированный лимит**
```env
TRAFFIC_SELECTION_MODE=fixed
FIXED_TRAFFIC_LIMIT_GB=100 # 0 = безлимит
@@ -1093,6 +1133,7 @@ SALES_MODE=tariffs # classic | tariffs
```
**Преимущества режима тарифов:**
- Готовые пакеты без сложного выбора параметров
- Упрощенный UX для пользователей
- Возможность создавать акционные тарифы
@@ -1104,6 +1145,7 @@ SALES_MODE=tariffs # classic | tariffs
**Базовая цена + Стоимость трафика + Доп. устройства + Доп. серверы**
**Пример расчета для подписки на 180 дней:**
- Базовый период: 400₽
- Трафик безлимит: 200₽/мес × 6 мес = 1200₽
- 4 устройства: 50₽/мес × 6 мес = 300₽
@@ -1190,6 +1232,7 @@ CONTEST_BUTTON_VISIBLE=true
```
**Возможности системы конкурсов:**
- Реферальные конкурсы с рейтингами
- Ежедневные игры с гарантированными призами
- Детальная статистика участников
@@ -1237,12 +1280,14 @@ CONTEST_BUTTON_VISIBLE=true
### 💤 **Для пользователей**
🧭 **Онбординг и доступ**
- 🌐 Выбор языка интерфейса (RU/EN), динамическая локализация
- 📜 Принятие правил, оферты и политики конфиденциальности
- 📡 Проверка подписки на обязательный канал
- 🔗 Deeplink-инвайты, UTM-кампании и реферальные коды
🛒 **Умная покупка подписок**
- 📅 Гибкие периоды (14—360 дней) со скидками
- 📊 Выбор трафика: фиксированный лимит, пакеты или безлимит
- 🌐 Автоматический выбор сервера или мультивыбор
@@ -1254,6 +1299,7 @@ CONTEST_BUTTON_VISIBLE=true
- 🛒 **Простая покупка** - упрощенный режим для быстрого оформления
🧪 **Тестовая подписка**
- 🎁 Гибко настраиваемый триал и welcome-цепочка
- 🔔 Уведомления об истечении и автоконверсия
- 💎 Автовыдача бонусов за кампании и инвайты
@@ -1262,6 +1308,7 @@ CONTEST_BUTTON_VISIBLE=true
- 💳 **Платный триал** с выбором способа оплаты
💰 **Платежи и баланс**
- ⭐ Telegram Stars
- 💳 Tribute
- 💳 YooKassa (СБП + банковские карты)
@@ -1281,6 +1328,7 @@ CONTEST_BUTTON_VISIBLE=true
- 🔄 **Умная автоактивация** подписки после пополнения баланса
📱 **Управление подписками**
- 📈 Реальный трафик, устройства и серверы
- 🌐 Переключение серверов и стран
- 📱 Сброс HWID
@@ -1290,11 +1338,13 @@ CONTEST_BUTTON_VISIBLE=true
- 📡 **Модем-режим** - дополнительные устройства для модемов
🛟 **Поддержка и самообслуживание**
- 🎫 **Система тикетов** с вложениями
- 📚 FAQ, правила, оферта и политика
- 💬 Быстрые ссылки на поддержку
🧩 **Бонусы и промо**
- 🎫 Промокоды на деньги, дни, триал подписку, промогруппу
- 🎁 **Персональные промо-предложения** от админов
- 💰 **Тестовый доступ к серверам** через промо-акции
@@ -1304,12 +1354,14 @@ CONTEST_BUTTON_VISIBLE=true
- 🔗 Генерация реферальных ссылок и QR кодов
🎮 **Конкурсы и игры**
- 🎲 **Ежедневные игры** с призами (баланс, подписка, промогруппа)
- 🏆 **Реферальные конкурсы** с рейтингами и наградами
- 📊 **Детальная статистика** участия в конкурсах
- 🎁 **Разнообразные призы** - баланс, дни подписки, промогруппы
💎 **Промо-группы и скидки**
- 🏷️ **Система промогрупп** с индивидуальными скидками с приоритетами
- 💰 Скидки на серверы, трафик и устройства
- 📊 **Скидочные уровни за траты** - прозрачная система лояльности
@@ -1317,6 +1369,7 @@ CONTEST_BUTTON_VISIBLE=true
- 🎯 **Скидки за длительные периоды** подписки для базовых юзеров
📱 **Mini App и гайды**
- 🖥️ **Полноценный личный кабинет** в Telegram WebApp
- 📊 Управление подпиской и параметрами
- 💳 Интегрированные платежи
@@ -1334,6 +1387,7 @@ CONTEST_BUTTON_VISIBLE=true
### ⚙️ **Для администраторов**
📊 **Аналитика и отчётность**
- 📈 Дашборды по пользователям, подпискам и трафику
- 💰 Детализация платежей по всем источникам
- 🧮 Продажи по тарифам, устройствам и странам
@@ -1344,6 +1398,7 @@ CONTEST_BUTTON_VISIBLE=true
- 📡 **Мониторинг трафика** и выявление злоупотреблений
💥 **Управление пользователями**
- 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры
- 💰 Ручное изменение баланса
- 📱 Изменение лимитов устройств, трафика, серверов
@@ -1361,6 +1416,7 @@ CONTEST_BUTTON_VISIBLE=true
- 🗑️ **Черный список** пользователей с управлением
🎯 **Продажи, маркетинг и удержание**
- 🎫 Промокоды
- 💳 Промо-группы со скидками
- 🎁 **Персональные промо-предложения** с поиском получателей
@@ -1376,6 +1432,7 @@ CONTEST_BUTTON_VISIBLE=true
- 👁️ **Видимость кнопок** - управление отображением конкурсов и реферальной программы
🛟 **Поддержка и модерация**
- 🎫 **Центр тикетов** с приоритетами и статусами
- ⏱️ **SLA таймеры** и автоуведомления
- 🧑‍⚖️ Роли модераторов с ограниченным доступом
@@ -1385,12 +1442,14 @@ CONTEST_BUTTON_VISIBLE=true
- 🔔 **Уведомления об ответах** на тикеты для админов
🔔 **Уведомления и коммуникации**
- 📢 **Топики для событий** (покупки, триалы, техработы)
- 🔔 Настройка уведомлений и расписаний
- 📨 **Управление контентом** - политика, оферта, FAQ
- 💬 Автоматические сообщения о задолженностях
🧰 **Обслуживание и DevOps**
- 🚧 Ручной и авто-режим техработ
- 🗒️ Просмотр системных логов и health-check
- 🔄 **Автосинхронизация Remnawave** по расписанию и при старте бота
@@ -1400,6 +1459,7 @@ CONTEST_BUTTON_VISIBLE=true
- 🔄 **Массовая синхронизация** пользователей с Remnawave
🗄️ **Бекапы и восстановление**
- 🗓️ **Умные автобекапы** с гибким расписанием
- 📦 Ручные бекапы с выбором содержимого
- 📤 Отправка архивов в выделенный чат/топик
@@ -1407,6 +1467,7 @@ CONTEST_BUTTON_VISIBLE=true
- ✅ Автоматическая синхронизация sequences после восстановления
💳 **Биллинг и настройки**
- ⚙️ **Управление ценами** без перезапуска бота
- 📘 **Управление пакетами трафика** (включение/отключение)
- 🧪 Тестовые платежи для каждого провайдера
@@ -1418,6 +1479,7 @@ CONTEST_BUTTON_VISIBLE=true
- 👁️ **Скрытие реферальной** программы
⚙️ **Remnawave**
- Синхронизация юзеров из панели в бота (Ручная/автоматическая по таймеру)
- Синхронизация юзеров из бота в панель
- Синхронизация сквадов(серверов) из панели в бота
@@ -1426,6 +1488,7 @@ CONTEST_BUTTON_VISIBLE=true
- Создание/Редактивание сквадов в боте
🗃️ **REST API для интеграций**
- 🔌 **FastAPI Web API** с полной документацией
- 🔒 Управление API-ключами и токенами
- 📊 Эндпоинты для подписок, пользователей, транзакций
@@ -1466,7 +1529,7 @@ CONTEST_BUTTON_VISIBLE=true
## 🚀 Производительность
| Пользователей | Память | CPU | Диск | Описание |
|---------------|--------|-----|------|----------|
| ------------- | ------ | ------- | ------ | ----------------- |
| **1,000** | 512MB | 1 vCPU | 10GB | ✅ Стартап |
| **10,000** | 2GB | 2 vCPU | 50GB | ✅ Малый бизнес |
| **50,000** | 4GB | 4 vCPU | 100GB | ✅ Средний бизнес |
@@ -1494,15 +1557,18 @@ CONTEST_BUTTON_VISIBLE=true
После запуска необходимо:
1. **📡 Синхронизация серверов** (обязательно!)
- Зайди в бот → **Админ панель****Подписки****Управление серверами**
- Нажми **Синхронизация** и дождись завершения
- Без этого пользователи не смогут выбирать страны!
2. **💥 Синхронизация пользователей** (если есть база)
- **Админ панель** → **Remnawave****Синхронизация**
- **Синхронизировать всех** → дождись импорта
3. **💳 Настройка платежных систем**
- **Telegram Stars**: Работает автоматически
- **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook`
- **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook`
@@ -1555,11 +1621,13 @@ ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # ID топика для тикет
## 🛠 Устранение неполадок
### 🥼 Health Checks
- **Unified сервер**: `http://localhost:8080/health/unified` (или `/health`, если административное API отключено)
- **Telegram webhook**: `http://localhost:8080/health/telegram-webhook`
- **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks`
### 🔧 Полезные команды
```bash
# Просмотр логов в реальном времени
docker compose logs -f bot
@@ -1589,7 +1657,7 @@ docker system prune
### 🚨 Частые проблемы и решения
| Проблема | Диагностика | Решение |
|----------|-------------|---------|
| -------------------------- | ---------------------------------------------------- | -------------------------------------- |
| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет |
| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL |
| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет |
@@ -1765,7 +1833,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
## 🎯 Ключевые особенности в цифрах
| Метрика | Значение |
|---------|----------|
| -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| 💳 **Платёжных систем** | 11 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA, Freekassa, CloudPayments) |
| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения |
| 📊 **Периодов подписки** | 6 (от 14 дней до года) |
@@ -1866,6 +1934,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
<summary><b>Какие требования к серверу?</b></summary>
Минимальные:
- 1 vCPU
- 512 MB RAM
- 10 GB диск
@@ -1873,6 +1942,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
- Docker и Docker Compose
Рекомендуемые:
- 2+ vCPU
- 2+ GB RAM
- 50+ GB SSD
@@ -1897,6 +1967,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
<summary><b>Как обновить бота?</b></summary>
**Вручную:**
```bash
cd /root/remnawave-bedolaga-telegram-bot
@@ -1926,10 +1997,12 @@ make reload-follow
<summary><b>Как сделать бэкап?</b></summary>
**Автоматически:**
- Настройте в `.env`: `BACKUP_AUTO_ENABLED=true`
- Бэкапы создаются по расписанию
**Через админ-панель:**
- Админ панель → Настройки → Бэкапы → Создать
</details>
+14
View File
@@ -17,6 +17,12 @@ from .promo import router as promo_router
from .notifications import router as notifications_router
from .info import router as info_router
from .branding import router as branding_router
from .wheel import router as wheel_router
from .admin_wheel import router as admin_wheel_router
from .admin_tariffs import router as admin_tariffs_router
from .admin_servers import router as admin_servers_router
from .admin_stats import router as admin_stats_router
from .media import router as media_router
# Main cabinet router
router = APIRouter(prefix="/cabinet", tags=["Cabinet"])
@@ -34,10 +40,18 @@ router.include_router(promo_router)
router.include_router(notifications_router)
router.include_router(info_router)
router.include_router(branding_router)
router.include_router(media_router)
# Wheel routes
router.include_router(wheel_router)
# Admin routes
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
router.include_router(admin_stats_router)
__all__ = ["router"]
+140
View File
@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.config import settings
from app.services.remnawave_service import RemnaWaveService
from app.services.system_settings_service import bot_configuration_service
from ..dependencies import get_cabinet_db, get_current_admin_user
@@ -419,3 +421,141 @@ async def copy_app_to_platform(
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
return {"status": "copied", "new_id": new_id, "target_platform": target_platform}
# ============ RemnaWave Config Routes ============
class RemnaWaveConfigStatus(BaseModel):
"""Status of RemnaWave config integration."""
enabled: bool
config_uuid: Optional[str] = None
class UpdateRemnaWaveUuidRequest(BaseModel):
"""Request to update RemnaWave config UUID."""
uuid: Optional[str] = None
def _get_remnawave_config_uuid() -> Optional[str]:
"""Get RemnaWave config UUID from system settings or env."""
try:
return bot_configuration_service.get_current_value("CABINET_REMNA_SUB_CONFIG")
except Exception:
return settings.CABINET_REMNA_SUB_CONFIG
@router.get("/remnawave/status", response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
):
"""Get RemnaWave config integration status."""
config_uuid = _get_remnawave_config_uuid()
return RemnaWaveConfigStatus(
enabled=bool(config_uuid),
config_uuid=config_uuid,
)
@router.put("/remnawave/uuid", response_model=RemnaWaveConfigStatus)
async def set_remnawave_config_uuid(
request: UpdateRemnaWaveUuidRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Set RemnaWave subscription config UUID."""
uuid_value = request.uuid.strip() if request.uuid else None
# Validate UUID format if provided
if uuid_value:
import re
uuid_pattern = re.compile(
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
)
if not uuid_pattern.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid UUID format",
)
try:
await bot_configuration_service.set_value(db, "CABINET_REMNA_SUB_CONFIG", uuid_value)
await db.commit()
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
except Exception as e:
logger.error(f"Error saving RemnaWave config UUID: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save configuration",
)
return RemnaWaveConfigStatus(
enabled=bool(uuid_value),
config_uuid=uuid_value,
)
@router.get("/remnawave/config")
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
):
"""
Fetch subscription page config from RemnaWave panel.
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
"""
config_uuid = _get_remnawave_config_uuid()
if not config_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="CABINET_REMNA_SUB_CONFIG is not configured",
)
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
config = await api.get_subscription_page_config(config_uuid)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
)
# Return the raw config data from RemnaWave
return {
"uuid": config.uuid,
"name": config.name,
"view_position": config.view_position,
"config": config.config,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching RemnaWave config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch config from RemnaWave: {str(e)}",
)
@router.get("/remnawave/configs")
async def list_remnawave_subscription_configs(
admin: User = Depends(get_current_admin_user),
):
"""List available subscription page configs from RemnaWave panel."""
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
configs = await api.get_subscription_page_configs()
return [
{
"uuid": c.uuid,
"name": c.name,
"view_position": c.view_position,
}
for c in configs
]
except Exception as e:
logger.error(f"Error listing RemnaWave configs: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch configs from RemnaWave: {str(e)}",
)
+330
View File
@@ -0,0 +1,330 @@
"""Admin routes for managing servers in cabinet."""
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, String
from sqlalchemy.orm import selectinload
from app.database.models import User, ServerSquad, Subscription, Tariff, PromoGroup
from app.database.crud.server_squad import (
get_all_server_squads,
get_server_squad_by_id,
update_server_squad,
update_server_squad_promo_groups,
sync_with_remnawave,
count_active_users_for_squad,
)
from app.services.subscription_service import SubscriptionService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.servers import (
ServerListResponse,
ServerListItem,
ServerDetailResponse,
ServerUpdateRequest,
ServerToggleResponse,
ServerTrialToggleResponse,
ServerStatsResponse,
ServerSyncResponse,
PromoGroupInfo,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/servers", tags=["Cabinet Admin Servers"])
async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> List[PromoGroupInfo]:
"""Get promo group info for server."""
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
all_groups = result.scalars().all()
selected_ids = {pg.id for pg in server.allowed_promo_groups} if server.allowed_promo_groups else set()
return [
PromoGroupInfo(
id=pg.id,
name=pg.name,
is_selected=pg.id in selected_ids,
)
for pg in all_groups
]
async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> List[str]:
"""Get list of tariff names using this server."""
# Get all tariffs and filter in Python since JSON array queries are DB-specific
result = await db.execute(select(Tariff.name, Tariff.allowed_squads))
tariff_names = []
for name, allowed_squads in result.fetchall():
if allowed_squads and squad_uuid in allowed_squads:
tariff_names.append(name)
return tariff_names
@router.get("", response_model=ServerListResponse)
async def list_servers(
include_unavailable: bool = True,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers."""
servers, total = await get_all_server_squads(
db,
available_only=not include_unavailable,
)
items = []
for server in servers:
items.append(ServerListItem(
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=server.is_available,
is_trial_eligible=server.is_trial_eligible,
price_kopeks=server.price_kopeks,
price_rubles=server.price_kopeks / 100,
max_users=server.max_users,
current_users=server.current_users or 0,
sort_order=server.sort_order,
is_full=server.is_full,
availability_status=server.availability_status,
created_at=server.created_at,
))
return ServerListResponse(servers=items, total=total)
@router.get("/{server_id}", response_model=ServerDetailResponse)
async def get_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed server info."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server not found",
)
promo_groups = await _get_server_promo_groups(db, server)
tariffs_using = await _get_tariffs_using_server(db, server.squad_uuid)
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
return ServerDetailResponse(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
original_name=server.original_name,
country_code=server.country_code,
description=server.description,
is_available=server.is_available,
is_trial_eligible=server.is_trial_eligible,
price_kopeks=server.price_kopeks,
price_rubles=server.price_kopeks / 100,
max_users=server.max_users,
current_users=server.current_users or 0,
sort_order=server.sort_order,
is_full=server.is_full,
availability_status=server.availability_status,
promo_groups=promo_groups,
active_subscriptions=active_subs,
tariffs_using=tariffs_using,
created_at=server.created_at,
updated_at=server.updated_at,
)
@router.put("/{server_id}", response_model=ServerDetailResponse)
async def update_existing_server(
server_id: int,
request: ServerUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing server."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server not found",
)
# Build updates dict
updates = {}
if request.display_name is not None:
updates["display_name"] = request.display_name
if request.description is not None:
updates["description"] = request.description
if request.country_code is not None:
updates["country_code"] = request.country_code
if request.is_available is not None:
updates["is_available"] = request.is_available
if request.is_trial_eligible is not None:
updates["is_trial_eligible"] = request.is_trial_eligible
if request.price_kopeks is not None:
updates["price_kopeks"] = request.price_kopeks
if request.max_users is not None:
updates["max_users"] = request.max_users if request.max_users > 0 else None
if request.sort_order is not None:
updates["sort_order"] = request.sort_order
if updates:
await update_server_squad(db, server_id, **updates)
# Update promo groups separately
if request.promo_group_ids is not None:
await update_server_squad_promo_groups(db, server_id, request.promo_group_ids)
logger.info(f"Admin {admin.id} updated server {server_id}")
return await get_server(server_id, admin, db)
@router.post("/{server_id}/toggle", response_model=ServerToggleResponse)
async def toggle_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server availability."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server not found",
)
new_status = not server.is_available
await update_server_squad(db, server_id, is_available=new_status)
status_text = "enabled" if new_status else "disabled"
logger.info(f"Admin {admin.id} {status_text} server {server_id}")
return ServerToggleResponse(
id=server_id,
is_available=new_status,
message=f"Server {status_text}",
)
@router.post("/{server_id}/trial", response_model=ServerTrialToggleResponse)
async def toggle_server_trial(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server trial eligibility."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server not found",
)
new_status = not server.is_trial_eligible
await update_server_squad(db, server_id, is_trial_eligible=new_status)
status_text = "enabled for trial" if new_status else "disabled for trial"
logger.info(f"Admin {admin.id} {status_text} server {server_id}")
return ServerTrialToggleResponse(
id=server_id,
is_trial_eligible=new_status,
message=f"Server {status_text}",
)
@router.get("/{server_id}/stats", response_model=ServerStatsResponse)
async def get_server_stats(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get server statistics."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server not found",
)
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
# Count trial subscriptions on this server
# Use LIKE query for JSON array since .contains() is DB-specific
trial_result = await db.execute(
select(func.count(Subscription.id))
.where(
Subscription.is_trial == True,
Subscription.status == "active",
func.cast(Subscription.connected_squads, String).like(f'%"{server.squad_uuid}"%'),
)
)
trial_count = trial_result.scalar() or 0
usage_percent = None
if server.max_users and server.max_users > 0:
usage_percent = round((server.current_users or 0) / server.max_users * 100, 1)
return ServerStatsResponse(
id=server_id,
display_name=server.display_name,
squad_uuid=server.squad_uuid,
current_users=server.current_users or 0,
max_users=server.max_users,
active_subscriptions=active_subs,
trial_subscriptions=trial_count,
usage_percent=usage_percent,
)
@router.post("/sync", response_model=ServerSyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync servers with RemnaWave."""
try:
subscription_service = SubscriptionService()
if not subscription_service.is_configured:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="RemnaWave is not configured",
)
# Get squads from RemnaWave
squads = await subscription_service.get_remnawave_squads()
if squads is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch squads from RemnaWave",
)
# Sync with database
created, updated, removed = await sync_with_remnawave(db, squads)
logger.info(f"Admin {admin.id} synced servers: +{created} ~{updated} -{removed}")
return ServerSyncResponse(
created=created,
updated=updated,
removed=removed,
message=f"Synced: {created} created, {updated} updated, {removed} removed",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to sync servers: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Sync failed: {str(e)}",
)
+428
View File
@@ -0,0 +1,428 @@
"""Admin routes for statistics dashboard in cabinet."""
import logging
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from sqlalchemy import select, func, and_
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period
from app.database.crud.server_squad import get_server_statistics
from app.services.remnawave_service import RemnaWaveService
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_admin_user
from app.database.models import User, Subscription, Tariff, SubscriptionStatus
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/stats", tags=["Cabinet Admin Stats"])
# ============ Schemas ============
class NodeStatus(BaseModel):
"""Node status info."""
uuid: str
name: str
address: str
is_connected: bool
is_disabled: bool
users_online: int
traffic_used_bytes: Optional[int] = None
uptime: Optional[str] = None
class NodesOverview(BaseModel):
"""Overview of all nodes."""
total: int
online: int
offline: int
disabled: int
total_users_online: int
nodes: List[NodeStatus]
class RevenueData(BaseModel):
"""Revenue data point."""
date: str
amount_kopeks: int
amount_rubles: float
class SubscriptionStats(BaseModel):
"""Subscription statistics."""
total: int
active: int
trial: int
paid: int
expired: int
purchased_today: int
purchased_week: int
purchased_month: int
trial_to_paid_conversion: float
class FinancialStats(BaseModel):
"""Financial statistics."""
income_today_kopeks: int
income_today_rubles: float
income_month_kopeks: int
income_month_rubles: float
income_total_kopeks: int
income_total_rubles: float
subscription_income_kopeks: int
subscription_income_rubles: float
class ServerStats(BaseModel):
"""Server statistics."""
total_servers: int
available_servers: int
servers_with_connections: int
total_revenue_kopeks: int
total_revenue_rubles: float
class TariffStatItem(BaseModel):
"""Statistics for a single tariff."""
tariff_id: int
tariff_name: str
active_subscriptions: int
trial_subscriptions: int
purchased_today: int
purchased_week: int
purchased_month: int
class TariffStats(BaseModel):
"""Tariff statistics."""
tariffs: List[TariffStatItem]
total_tariff_subscriptions: int
class DashboardStats(BaseModel):
"""Complete dashboard statistics."""
nodes: NodesOverview
subscriptions: SubscriptionStats
financial: FinancialStats
servers: ServerStats
revenue_chart: List[RevenueData]
tariff_stats: Optional[TariffStats] = None
# ============ Routes ============
@router.get("/dashboard", response_model=DashboardStats)
async def get_dashboard_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get complete dashboard statistics for admin panel."""
try:
# Get nodes status from RemnaWave
nodes_data = await _get_nodes_overview()
# Get subscription statistics
sub_stats = await get_subscriptions_statistics(db)
# Get financial statistics
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
trans_stats = await get_transactions_statistics(db, month_start, now)
# Get revenue chart data (last 30 days)
revenue_data = await get_revenue_by_period(db, days=30)
# Get server statistics
server_stats = await get_server_statistics(db)
# Get tariff statistics
tariff_stats = await _get_tariff_stats(db)
# Build response
return DashboardStats(
nodes=nodes_data,
subscriptions=SubscriptionStats(
total=sub_stats.get("total_subscriptions", 0),
active=sub_stats.get("active_subscriptions", 0),
trial=sub_stats.get("trial_subscriptions", 0),
paid=sub_stats.get("paid_subscriptions", 0),
expired=sub_stats.get("total_subscriptions", 0) - sub_stats.get("active_subscriptions", 0),
purchased_today=sub_stats.get("purchased_today", 0),
purchased_week=sub_stats.get("purchased_week", 0),
purchased_month=sub_stats.get("purchased_month", 0),
trial_to_paid_conversion=sub_stats.get("trial_to_paid_conversion", 0.0),
),
financial=FinancialStats(
income_today_kopeks=trans_stats.get("today", {}).get("income_kopeks", 0),
income_today_rubles=trans_stats.get("today", {}).get("income_kopeks", 0) / 100,
income_month_kopeks=trans_stats.get("totals", {}).get("income_kopeks", 0),
income_month_rubles=trans_stats.get("totals", {}).get("income_kopeks", 0) / 100,
income_total_kopeks=trans_stats.get("totals", {}).get("income_kopeks", 0),
income_total_rubles=trans_stats.get("totals", {}).get("income_kopeks", 0) / 100,
subscription_income_kopeks=trans_stats.get("totals", {}).get("subscription_income_kopeks", 0),
subscription_income_rubles=trans_stats.get("totals", {}).get("subscription_income_kopeks", 0) / 100,
),
servers=ServerStats(
total_servers=server_stats.get("total_servers", 0),
available_servers=server_stats.get("available_servers", 0),
servers_with_connections=server_stats.get("servers_with_connections", 0),
total_revenue_kopeks=server_stats.get("total_revenue_kopeks", 0),
total_revenue_rubles=server_stats.get("total_revenue_rubles", 0.0),
),
revenue_chart=[
RevenueData(
date=item.get("date", "").isoformat() if hasattr(item.get("date", ""), "isoformat") else str(item.get("date", "")),
amount_kopeks=item.get("amount_kopeks", 0),
amount_rubles=item.get("amount_kopeks", 0) / 100,
)
for item in revenue_data
],
tariff_stats=tariff_stats,
)
except Exception as e:
logger.error(f"Failed to get dashboard stats: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load dashboard statistics",
)
@router.get("/nodes", response_model=NodesOverview)
async def get_nodes_status(
admin: User = Depends(get_current_admin_user),
):
"""Get status of all nodes."""
try:
return await _get_nodes_overview()
except Exception as e:
logger.error(f"Failed to get nodes status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load nodes status",
)
@router.post("/nodes/{node_uuid}/restart")
async def restart_node(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
):
"""Restart a node."""
try:
service = RemnaWaveService()
success = await service.manage_node(node_uuid, "restart")
if success:
logger.info(f"Admin {admin.id} restarted node {node_uuid}")
return {"success": True, "message": "Node restart initiated"}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to restart node",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to restart node {node_uuid}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to restart node",
)
@router.post("/nodes/{node_uuid}/toggle")
async def toggle_node(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
):
"""Enable or disable a node."""
try:
service = RemnaWaveService()
nodes = await service.get_all_nodes()
node = next((n for n in nodes if n.get("uuid") == node_uuid), None)
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Node not found",
)
is_disabled = node.get("is_disabled", False)
action = "enable" if is_disabled else "disable"
success = await service.manage_node(node_uuid, action)
if success:
logger.info(f"Admin {admin.id} {action}d node {node_uuid}")
return {"success": True, "message": f"Node {action}d", "is_disabled": not is_disabled}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to {action} node",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to toggle node {node_uuid}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to toggle node",
)
async def _get_nodes_overview() -> NodesOverview:
"""Get overview of all nodes."""
try:
service = RemnaWaveService()
nodes = await service.get_all_nodes()
total = len(nodes)
online = sum(1 for n in nodes if n.get("is_connected") and not n.get("is_disabled"))
disabled = sum(1 for n in nodes if n.get("is_disabled"))
offline = total - online - disabled
total_users_online = sum(n.get("users_online", 0) or 0 for n in nodes)
node_statuses = [
NodeStatus(
uuid=n.get("uuid", ""),
name=n.get("name", "Unknown"),
address=n.get("address", ""),
is_connected=n.get("is_connected", False),
is_disabled=n.get("is_disabled", False),
users_online=n.get("users_online", 0) or 0,
traffic_used_bytes=n.get("traffic_used_bytes"),
uptime=n.get("uptime"),
)
for n in nodes
]
return NodesOverview(
total=total,
online=online,
offline=offline,
disabled=disabled,
total_users_online=total_users_online,
nodes=node_statuses,
)
except Exception as e:
logger.warning(f"Failed to get nodes from RemnaWave: {e}")
# Return empty data if RemnaWave is unavailable
return NodesOverview(
total=0,
online=0,
offline=0,
disabled=0,
total_users_online=0,
nodes=[],
)
async def _get_tariff_stats(db: AsyncSession) -> Optional[TariffStats]:
"""Get statistics for all tariffs."""
try:
# Получаем ВСЕ тарифы (включая неактивные) для статистики
tariffs_result = await db.execute(
select(Tariff)
.order_by(Tariff.display_order)
)
tariffs = tariffs_result.scalars().all()
if not tariffs:
logger.info("📊 Нет тарифов в системе, пропускаем статистику")
return None
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
tariff_items = []
total_tariff_subscriptions = 0
for tariff in tariffs:
# Активные подписки на этом тарифе
active_result = await db.execute(
select(func.count(Subscription.id))
.where(
Subscription.tariff_id == tariff.id,
Subscription.status == SubscriptionStatus.ACTIVE.value
)
)
active_count = active_result.scalar() or 0
# Триальные подписки на этом тарифе
trial_result = await db.execute(
select(func.count(Subscription.id))
.where(
Subscription.tariff_id == tariff.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == True
)
)
trial_count = trial_result.scalar() or 0
# Куплено сегодня (не триальные)
today_result = await db.execute(
select(func.count(Subscription.id))
.where(
Subscription.tariff_id == tariff.id,
Subscription.created_at >= today_start,
Subscription.is_trial == False
)
)
purchased_today = today_result.scalar() or 0
# Куплено за неделю
week_result = await db.execute(
select(func.count(Subscription.id))
.where(
Subscription.tariff_id == tariff.id,
Subscription.created_at >= week_ago,
Subscription.is_trial == False
)
)
purchased_week = week_result.scalar() or 0
# Куплено за месяц
month_result = await db.execute(
select(func.count(Subscription.id))
.where(
Subscription.tariff_id == tariff.id,
Subscription.created_at >= month_ago,
Subscription.is_trial == False
)
)
purchased_month = month_result.scalar() or 0
logger.info(f"📊 Тариф '{tariff.name}': активных={active_count}, триал={trial_count}")
tariff_items.append(TariffStatItem(
tariff_id=tariff.id,
tariff_name=tariff.name,
active_subscriptions=active_count,
trial_subscriptions=trial_count,
purchased_today=purchased_today,
purchased_week=purchased_week,
purchased_month=purchased_month,
))
total_tariff_subscriptions += active_count
logger.info(f"📊 Всего подписок по тарифам: {total_tariff_subscriptions}")
return TariffStats(
tariffs=tariff_items,
total_tariff_subscriptions=total_tariff_subscriptions,
)
except Exception as e:
logger.error(f"Failed to get tariff stats: {e}", exc_info=True)
return None
+520
View File
@@ -0,0 +1,520 @@
"""Admin routes for managing tariffs in cabinet."""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database.models import User, Tariff, Subscription, ServerSquad, PromoGroup
from app.database.crud.tariff import (
get_all_tariffs,
get_tariff_by_id,
create_tariff,
update_tariff,
delete_tariff,
get_tariff_subscriptions_count,
set_tariff_promo_groups,
load_period_prices_from_db,
)
from app.database.crud.server_squad import get_all_server_squads
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tariffs import (
TariffListResponse,
TariffListItem,
TariffDetailResponse,
TariffCreateRequest,
TariffUpdateRequest,
TariffToggleResponse,
TariffTrialResponse,
TariffStatsResponse,
PeriodPrice,
ServerInfo,
PromoGroupInfo,
ServerTrafficLimit,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/tariffs", tags=["Cabinet Admin Tariffs"])
async def _get_tariff_servers(
db: AsyncSession,
allowed_squads: List[str],
server_traffic_limits: dict = None
) -> List[ServerInfo]:
"""Get server info for tariff."""
servers, _ = await get_all_server_squads(db, available_only=False)
limits = server_traffic_limits or {}
result = []
for server in servers:
# Получаем индивидуальный лимит трафика для сервера
server_limit = None
if server.squad_uuid in limits:
limit_data = limits[server.squad_uuid]
if isinstance(limit_data, dict) and 'traffic_limit_gb' in limit_data:
server_limit = limit_data['traffic_limit_gb']
elif isinstance(limit_data, int):
server_limit = limit_data
result.append(ServerInfo(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
country_code=server.country_code,
is_selected=server.squad_uuid in allowed_squads,
traffic_limit_gb=server_limit,
))
return result
async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> List[PromoGroupInfo]:
"""Get promo group info for tariff."""
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
all_groups = result.scalars().all()
selected_ids = {pg.id for pg in tariff.allowed_promo_groups} if tariff.allowed_promo_groups else set()
return [
PromoGroupInfo(
id=pg.id,
name=pg.name,
is_selected=pg.id in selected_ids,
)
for pg in all_groups
]
def _period_prices_to_list(period_prices: dict) -> List[PeriodPrice]:
"""Convert period_prices dict to list."""
if not period_prices:
return []
return [
PeriodPrice(days=int(days), price_kopeks=price)
for days, price in sorted(period_prices.items(), key=lambda x: int(x[0]))
]
def _period_prices_to_dict(period_prices: List[PeriodPrice]) -> dict:
"""Convert period_prices list to dict."""
return {str(pp.days): pp.price_kopeks for pp in period_prices}
@router.get("", response_model=TariffListResponse)
async def list_tariffs(
include_inactive: bool = True,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all tariffs."""
tariffs = await get_all_tariffs(db, include_inactive=include_inactive)
items = []
for tariff in tariffs:
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
items.append(TariffListItem(
id=tariff.id,
name=tariff.name,
description=tariff.description,
is_active=tariff.is_active,
is_trial_available=tariff.is_trial_available,
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
allow_traffic_topup=tariff.allow_traffic_topup,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
display_order=tariff.display_order,
servers_count=len(tariff.allowed_squads or []),
subscriptions_count=subs_count,
created_at=tariff.created_at,
))
return TariffListResponse(tariffs=items, total=len(items))
@router.get("/available-servers", response_model=List[ServerInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers for tariff selection."""
servers, _ = await get_all_server_squads(db, available_only=False)
return [
ServerInfo(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
country_code=server.country_code,
is_selected=False,
)
for server in servers
]
@router.get("/{tariff_id}", response_model=TariffDetailResponse)
async def get_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed tariff info."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
)
allowed_squads = tariff.allowed_squads or []
server_traffic_limits = tariff.server_traffic_limits or {}
servers = await _get_tariff_servers(db, allowed_squads, server_traffic_limits)
promo_groups = await _get_tariff_promo_groups(db, tariff)
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
# Преобразуем server_traffic_limits в формат для схемы
server_limits_response = {}
for uuid, limit_data in server_traffic_limits.items():
if isinstance(limit_data, dict):
server_limits_response[uuid] = ServerTrafficLimit(**limit_data)
elif isinstance(limit_data, int):
server_limits_response[uuid] = ServerTrafficLimit(traffic_limit_gb=limit_data)
return TariffDetailResponse(
id=tariff.id,
name=tariff.name,
description=tariff.description,
is_active=tariff.is_active,
is_trial_available=tariff.is_trial_available,
allow_traffic_topup=tariff.allow_traffic_topup,
traffic_topup_enabled=tariff.traffic_topup_enabled,
traffic_topup_packages=tariff.traffic_topup_packages or {},
max_topup_traffic_gb=tariff.max_topup_traffic_gb,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
device_price_kopeks=tariff.device_price_kopeks,
max_device_limit=tariff.max_device_limit,
tier_level=tariff.tier_level,
display_order=tariff.display_order,
period_prices=_period_prices_to_list(tariff.period_prices),
allowed_squads=allowed_squads,
server_traffic_limits=server_limits_response,
servers=servers,
promo_groups=promo_groups,
subscriptions_count=subs_count,
# Произвольное количество дней
custom_days_enabled=tariff.custom_days_enabled,
price_per_day_kopeks=tariff.price_per_day_kopeks,
min_days=tariff.min_days,
max_days=tariff.max_days,
# Произвольный трафик при покупке
custom_traffic_enabled=tariff.custom_traffic_enabled,
traffic_price_per_gb_kopeks=tariff.traffic_price_per_gb_kopeks,
min_traffic_gb=tariff.min_traffic_gb,
max_traffic_gb=tariff.max_traffic_gb,
# Дневной тариф
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=tariff.traffic_reset_mode,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@router.post("", response_model=TariffDetailResponse)
async def create_new_tariff(
request: TariffCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new tariff."""
period_prices_dict = _period_prices_to_dict(request.period_prices)
# Преобразуем ServerTrafficLimit в dict для хранения
server_limits_dict = {
uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()
} if request.server_traffic_limits else {}
tariff = await create_tariff(
db=db,
name=request.name,
description=request.description,
is_active=request.is_active,
allow_traffic_topup=request.allow_traffic_topup,
traffic_topup_enabled=request.traffic_topup_enabled,
traffic_topup_packages=request.traffic_topup_packages,
max_topup_traffic_gb=request.max_topup_traffic_gb,
traffic_limit_gb=request.traffic_limit_gb,
device_limit=request.device_limit,
device_price_kopeks=request.device_price_kopeks,
max_device_limit=request.max_device_limit,
tier_level=request.tier_level,
period_prices=period_prices_dict,
allowed_squads=request.allowed_squads,
server_traffic_limits=server_limits_dict,
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
# Произвольное количество дней
custom_days_enabled=request.custom_days_enabled,
price_per_day_kopeks=request.price_per_day_kopeks,
min_days=request.min_days,
max_days=request.max_days,
# Произвольный трафик при покупке
custom_traffic_enabled=request.custom_traffic_enabled,
traffic_price_per_gb_kopeks=request.traffic_price_per_gb_kopeks,
min_traffic_gb=request.min_traffic_gb,
max_traffic_gb=request.max_traffic_gb,
# Дневной тариф
is_daily=request.is_daily,
daily_price_kopeks=request.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=request.traffic_reset_mode,
)
logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}")
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
# Return full detail
return await get_tariff(tariff.id, admin, db)
@router.put("/{tariff_id}", response_model=TariffDetailResponse)
async def update_existing_tariff(
tariff_id: int,
request: TariffUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing tariff."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
)
# Build updates dict
updates = {}
if request.name is not None:
updates["name"] = request.name
if request.description is not None:
updates["description"] = request.description
if request.is_active is not None:
updates["is_active"] = request.is_active
if request.allow_traffic_topup is not None:
updates["allow_traffic_topup"] = request.allow_traffic_topup
if request.traffic_topup_enabled is not None:
updates["traffic_topup_enabled"] = request.traffic_topup_enabled
if request.traffic_topup_packages is not None:
updates["traffic_topup_packages"] = request.traffic_topup_packages
if request.max_topup_traffic_gb is not None:
updates["max_topup_traffic_gb"] = request.max_topup_traffic_gb
if request.traffic_limit_gb is not None:
updates["traffic_limit_gb"] = request.traffic_limit_gb
if request.device_limit is not None:
updates["device_limit"] = request.device_limit
if request.device_price_kopeks is not None:
updates["device_price_kopeks"] = request.device_price_kopeks
if request.max_device_limit is not None:
updates["max_device_limit"] = request.max_device_limit
if request.tier_level is not None:
updates["tier_level"] = request.tier_level
if request.display_order is not None:
updates["display_order"] = request.display_order
if request.period_prices is not None:
updates["period_prices"] = _period_prices_to_dict(request.period_prices)
if request.allowed_squads is not None:
updates["allowed_squads"] = request.allowed_squads
if request.server_traffic_limits is not None:
# Преобразуем ServerTrafficLimit в dict для хранения
updates["server_traffic_limits"] = {
uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()
}
# Произвольное количество дней
if request.custom_days_enabled is not None:
updates["custom_days_enabled"] = request.custom_days_enabled
if request.price_per_day_kopeks is not None:
updates["price_per_day_kopeks"] = request.price_per_day_kopeks
if request.min_days is not None:
updates["min_days"] = request.min_days
if request.max_days is not None:
updates["max_days"] = request.max_days
# Произвольный трафик при покупке
if request.custom_traffic_enabled is not None:
updates["custom_traffic_enabled"] = request.custom_traffic_enabled
if request.traffic_price_per_gb_kopeks is not None:
updates["traffic_price_per_gb_kopeks"] = request.traffic_price_per_gb_kopeks
if request.min_traffic_gb is not None:
updates["min_traffic_gb"] = request.min_traffic_gb
if request.max_traffic_gb is not None:
updates["max_traffic_gb"] = request.max_traffic_gb
# Дневной тариф
if request.is_daily is not None:
updates["is_daily"] = request.is_daily
if request.daily_price_kopeks is not None:
updates["daily_price_kopeks"] = request.daily_price_kopeks
# Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
if 'traffic_reset_mode' in request.model_fields_set:
updates["traffic_reset_mode"] = request.traffic_reset_mode
if updates:
await update_tariff(db, tariff, **updates)
# Update promo groups separately
if request.promo_group_ids is not None:
await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids)
logger.info(f"Admin {admin.id} updated tariff {tariff_id}")
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return await get_tariff(tariff_id, admin, db)
@router.delete("/{tariff_id}")
async def delete_existing_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a tariff."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
)
# Check if tariff has subscriptions
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
if subs_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete tariff with {subs_count} active subscriptions",
)
await delete_tariff(db, tariff)
logger.info(f"Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}")
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return {"message": "Tariff deleted successfully"}
@router.post("/{tariff_id}/toggle", response_model=TariffToggleResponse)
async def toggle_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff active status."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
)
new_status = not tariff.is_active
await update_tariff(db, tariff, is_active=new_status)
status_text = "activated" if new_status else "deactivated"
logger.info(f"Admin {admin.id} {status_text} tariff {tariff_id}")
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return TariffToggleResponse(
id=tariff_id,
is_active=new_status,
message=f"Tariff {status_text}",
)
@router.post("/{tariff_id}/trial", response_model=TariffTrialResponse)
async def toggle_trial_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff trial availability."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
)
new_status = not tariff.is_trial_available
await update_tariff(db, tariff, is_trial_available=new_status)
status_text = "set as trial" if new_status else "removed from trial"
logger.info(f"Admin {admin.id} {status_text} tariff {tariff_id}")
return TariffTrialResponse(
id=tariff_id,
is_trial_available=new_status,
message=f"Tariff {status_text}",
)
@router.get("/{tariff_id}/stats", response_model=TariffStatsResponse)
async def get_tariff_stats(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get tariff statistics."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
)
# Count subscriptions
total_result = await db.execute(
select(func.count(Subscription.id))
.where(Subscription.tariff_id == tariff_id)
)
total_count = total_result.scalar() or 0
# Count active subscriptions
active_result = await db.execute(
select(func.count(Subscription.id))
.where(
Subscription.tariff_id == tariff_id,
Subscription.status == "active",
)
)
active_count = active_result.scalar() or 0
# Count trial subscriptions
trial_result = await db.execute(
select(func.count(Subscription.id))
.where(
Subscription.tariff_id == tariff_id,
Subscription.is_trial == True,
)
)
trial_count = trial_result.scalar() or 0
# TODO: Calculate revenue from transactions
revenue_kopeks = 0
return TariffStatsResponse(
id=tariff_id,
name=tariff.name,
subscriptions_count=total_count,
active_subscriptions=active_count,
trial_subscriptions=trial_count,
revenue_kopeks=revenue_kopeks,
revenue_rubles=revenue_kopeks / 100,
)
+116
View File
@@ -103,6 +103,24 @@ class AdminStatsResponse(BaseModel):
closed: int
class TicketSettingsResponse(BaseModel):
"""Ticket system settings."""
sla_enabled: bool
sla_minutes: int
sla_check_interval_seconds: int
sla_reminder_cooldown_minutes: int
support_system_mode: str # tickets, contact, both
class TicketSettingsUpdateRequest(BaseModel):
"""Update ticket settings."""
sla_enabled: Optional[bool] = None
sla_minutes: Optional[int] = Field(None, ge=1, le=1440, description="SLA time in minutes (1-1440)")
sla_check_interval_seconds: Optional[int] = Field(None, ge=30, le=600, description="Check interval (30-600 seconds)")
sla_reminder_cooldown_minutes: Optional[int] = Field(None, ge=1, le=120, description="Reminder cooldown (1-120 minutes)")
support_system_mode: Optional[str] = Field(None, description="Support mode: tickets, contact, both")
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
return TicketMessageResponse(
@@ -111,6 +129,7 @@ def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
created_at=message.created_at,
)
@@ -448,3 +467,100 @@ async def update_ticket_priority(
user=user_info,
messages=messages_response,
)
@router.get("/settings", response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
)
@router.patch("/settings", response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
import os
from pathlib import Path
# Validate support_system_mode
if request.support_system_mode is not None:
mode = request.support_system_mode.strip().lower()
if mode not in {"tickets", "contact", "both"}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid support_system_mode. Must be: tickets, contact, or both",
)
# Update in-memory settings
if request.sla_enabled is not None:
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
if request.sla_minutes is not None:
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
if request.sla_check_interval_seconds is not None:
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Try to persist to .env file
try:
env_file = Path(".env")
if env_file.exists():
lines = env_file.read_text().splitlines()
updates = {}
if request.sla_enabled is not None:
updates["SUPPORT_TICKET_SLA_ENABLED"] = str(request.sla_enabled).lower()
if request.sla_minutes is not None:
updates["SUPPORT_TICKET_SLA_MINUTES"] = str(request.sla_minutes)
if request.sla_check_interval_seconds is not None:
updates["SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS"] = str(request.sla_check_interval_seconds)
if request.sla_reminder_cooldown_minutes is not None:
updates["SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES"] = str(request.sla_reminder_cooldown_minutes)
if request.support_system_mode is not None:
updates["SUPPORT_SYSTEM_MODE"] = request.support_system_mode.strip().lower()
new_lines = []
updated_keys = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f"{key}="):
new_lines.append(f"{key}={value}")
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
# Add any keys that weren't found
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f"{key}={value}")
env_file.write_text("\n".join(new_lines) + "\n")
logger.info(f"Updated ticket settings in .env file")
except Exception as e:
logger.warning(f"Failed to update .env file: {e}")
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
)
+387
View File
@@ -0,0 +1,387 @@
"""
API роуты колеса удачи для администраторов.
"""
import logging
import math
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.database.crud.wheel import (
get_or_create_wheel_config,
update_wheel_config,
get_wheel_prizes,
get_wheel_prize_by_id,
create_wheel_prize,
update_wheel_prize,
delete_wheel_prize,
reorder_wheel_prizes,
get_all_spins,
get_wheel_statistics,
)
from app.services.wheel_service import wheel_service
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
from app.cabinet.schemas.wheel import (
AdminWheelConfigResponse,
WheelPrizeAdminResponse,
UpdateWheelConfigRequest,
CreatePrizeRequest,
UpdatePrizeRequest,
ReorderPrizesRequest,
AdminSpinsResponse,
AdminSpinItem,
WheelStatisticsResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/wheel", tags=["Admin Fortune Wheel"])
@router.get("/config", response_model=AdminWheelConfigResponse)
async def get_admin_wheel_config(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить полную конфигурацию колеса."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=False)
prizes_response = [
WheelPrizeAdminResponse(
id=p.id,
config_id=p.config_id,
prize_type=p.prize_type,
prize_value=p.prize_value,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_value_kopeks=p.prize_value_kopeks,
sort_order=p.sort_order,
manual_probability=p.manual_probability,
is_active=p.is_active,
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
promo_subscription_days=p.promo_subscription_days or 0,
promo_traffic_gb=p.promo_traffic_gb or 0,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in prizes
]
return AdminWheelConfigResponse(
id=config.id,
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars,
spin_cost_days=config.spin_cost_days,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
rtp_percent=config.rtp_percent,
daily_spin_limit=config.daily_spin_limit,
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
promo_prefix=config.promo_prefix,
promo_validity_days=config.promo_validity_days,
prizes=prizes_response,
created_at=config.created_at,
updated_at=config.updated_at,
)
@router.put("/config", response_model=AdminWheelConfigResponse)
async def update_admin_wheel_config(
request: UpdateWheelConfigRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить конфигурацию колеса."""
update_data = request.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No fields to update",
)
config = await update_wheel_config(db, **update_data)
logger.info(f"🎡 Admin {admin.telegram_id} updated wheel config: {update_data}")
# Возвращаем полную конфигурацию
prizes = await get_wheel_prizes(db, config.id, active_only=False)
prizes_response = [
WheelPrizeAdminResponse(
id=p.id,
config_id=p.config_id,
prize_type=p.prize_type,
prize_value=p.prize_value,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_value_kopeks=p.prize_value_kopeks,
sort_order=p.sort_order,
manual_probability=p.manual_probability,
is_active=p.is_active,
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
promo_subscription_days=p.promo_subscription_days or 0,
promo_traffic_gb=p.promo_traffic_gb or 0,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in prizes
]
return AdminWheelConfigResponse(
id=config.id,
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars,
spin_cost_days=config.spin_cost_days,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
rtp_percent=config.rtp_percent,
daily_spin_limit=config.daily_spin_limit,
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
promo_prefix=config.promo_prefix,
promo_validity_days=config.promo_validity_days,
prizes=prizes_response,
created_at=config.created_at,
updated_at=config.updated_at,
)
@router.get("/prizes", response_model=List[WheelPrizeAdminResponse])
async def get_prizes(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить список призов."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=False)
return [
WheelPrizeAdminResponse(
id=p.id,
config_id=p.config_id,
prize_type=p.prize_type,
prize_value=p.prize_value,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_value_kopeks=p.prize_value_kopeks,
sort_order=p.sort_order,
manual_probability=p.manual_probability,
is_active=p.is_active,
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
promo_subscription_days=p.promo_subscription_days or 0,
promo_traffic_gb=p.promo_traffic_gb or 0,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in prizes
]
@router.post("/prizes", response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
async def create_prize(
request: CreatePrizeRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Создать новый приз."""
config = await get_or_create_wheel_config(db)
prize = await create_wheel_prize(
db=db,
config_id=config.id,
prize_type=request.prize_type.value,
prize_value=request.prize_value,
display_name=request.display_name,
prize_value_kopeks=request.prize_value_kopeks,
emoji=request.emoji,
color=request.color,
sort_order=request.sort_order,
manual_probability=request.manual_probability,
is_active=request.is_active,
promo_balance_bonus_kopeks=request.promo_balance_bonus_kopeks,
promo_subscription_days=request.promo_subscription_days,
promo_traffic_gb=request.promo_traffic_gb,
)
logger.info(f"🎁 Admin {admin.telegram_id} created prize: {prize.display_name}")
return WheelPrizeAdminResponse(
id=prize.id,
config_id=prize.config_id,
prize_type=prize.prize_type,
prize_value=prize.prize_value,
display_name=prize.display_name,
emoji=prize.emoji,
color=prize.color,
prize_value_kopeks=prize.prize_value_kopeks,
sort_order=prize.sort_order,
manual_probability=prize.manual_probability,
is_active=prize.is_active,
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
promo_subscription_days=prize.promo_subscription_days or 0,
promo_traffic_gb=prize.promo_traffic_gb or 0,
created_at=prize.created_at,
updated_at=prize.updated_at,
)
@router.put("/prizes/{prize_id}", response_model=WheelPrizeAdminResponse)
async def update_prize(
prize_id: int,
request: UpdatePrizeRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить приз."""
update_data = request.model_dump(exclude_unset=True)
# Конвертируем enum в строку если есть
if 'prize_type' in update_data and update_data['prize_type']:
update_data['prize_type'] = update_data['prize_type'].value
if not update_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No fields to update",
)
prize = await update_wheel_prize(db, prize_id, **update_data)
if not prize:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Prize not found",
)
logger.info(f"🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}")
return WheelPrizeAdminResponse(
id=prize.id,
config_id=prize.config_id,
prize_type=prize.prize_type,
prize_value=prize.prize_value,
display_name=prize.display_name,
emoji=prize.emoji,
color=prize.color,
prize_value_kopeks=prize.prize_value_kopeks,
sort_order=prize.sort_order,
manual_probability=prize.manual_probability,
is_active=prize.is_active,
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
promo_subscription_days=prize.promo_subscription_days or 0,
promo_traffic_gb=prize.promo_traffic_gb or 0,
created_at=prize.created_at,
updated_at=prize.updated_at,
)
@router.delete("/prizes/{prize_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_prize_endpoint(
prize_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Удалить приз."""
success = await delete_wheel_prize(db, prize_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Prize not found",
)
logger.info(f"🗑️ Admin {admin.telegram_id} deleted prize {prize_id}")
@router.post("/prizes/reorder", status_code=status.HTTP_200_OK)
async def reorder_prizes(
request: ReorderPrizesRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Переупорядочить призы."""
await reorder_wheel_prizes(db, request.prize_ids)
logger.info(f"🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}")
return {"success": True}
@router.get("/statistics", response_model=WheelStatisticsResponse)
async def get_statistics(
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить статистику колеса."""
stats = await wheel_service.get_statistics(db, date_from, date_to)
return WheelStatisticsResponse(
total_spins=stats["total_spins"],
total_revenue_kopeks=stats["total_revenue_kopeks"],
total_payout_kopeks=stats["total_payout_kopeks"],
actual_rtp_percent=stats["actual_rtp_percent"],
configured_rtp_percent=stats["configured_rtp_percent"],
spins_by_payment_type=stats["spins_by_payment_type"],
prizes_distribution=stats["prizes_distribution"],
top_wins=stats["top_wins"],
period_from=stats["period_from"],
period_to=stats["period_to"],
)
@router.get("/spins", response_model=AdminSpinsResponse)
async def get_all_spins_endpoint(
user_id: Optional[int] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить все спины с фильтрами."""
offset = (page - 1) * per_page
spins, total = await get_all_spins(
db,
user_id=user_id,
date_from=date_from,
date_to=date_to,
limit=per_page,
offset=offset,
)
items = [
AdminSpinItem(
id=spin.id,
user_id=spin.user_id,
username=spin.user.username if spin.user else None,
payment_type=spin.payment_type,
payment_amount=spin.payment_amount,
payment_value_kopeks=spin.payment_value_kopeks,
prize_type=spin.prize_type,
prize_value=spin.prize_value,
prize_display_name=spin.prize_display_name,
prize_value_kopeks=spin.prize_value_kopeks,
is_applied=spin.is_applied,
created_at=spin.created_at,
)
for spin in spins
]
pages = math.ceil(total / per_page) if total > 0 else 1
return AdminSpinsResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
+330 -12
View File
@@ -2,8 +2,10 @@
import logging
import math
import time
from typing import List, Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
@@ -23,6 +25,8 @@ from ..schemas.balance import (
PaymentMethodResponse,
TopUpRequest,
TopUpResponse,
StarsInvoiceRequest,
StarsInvoiceResponse,
)
logger = logging.getLogger(__name__)
@@ -79,20 +83,25 @@ async def get_transactions(
result = await db.execute(query)
transactions = result.scalars().all()
items = [
TransactionResponse(
items = []
for t in transactions:
# Determine sign based on transaction type
# Credits (positive): DEPOSIT, REFERRAL_REWARD, REFUND, POLL_REWARD
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL
is_debit = t.type in ['subscription_payment', 'withdrawal']
amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks)
items.append(TransactionResponse(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=amount_kopeks,
amount_rubles=amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
created_at=t.created_at,
completed_at=t.completed_at,
)
for t in transactions
]
))
pages = math.ceil(total / per_page) if total > 0 else 1
@@ -165,7 +174,7 @@ async def get_payment_methods():
is_available=True,
))
# PAL24
# PAL24 - add options for card/sbp
if settings.is_pal24_enabled():
methods.append(PaymentMethodResponse(
id="pal24",
@@ -174,17 +183,33 @@ async def get_payment_methods():
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
is_available=True,
options=[
{"id": "sbp", "name": "🏦 СБП", "description": "Система быстрых платежей"},
{"id": "card", "name": "💳 Карта", "description": "Банковская карта"},
],
))
# Platega
# Platega - add options for different payment methods
if settings.is_platega_enabled():
platega_methods = settings.get_platega_active_methods()
definitions = settings.get_platega_method_definitions()
platega_options = []
for method_code in platega_methods:
info = definitions.get(method_code, {})
platega_options.append({
"id": str(method_code),
"name": info.get("title") or info.get("name") or f"Platega {method_code}",
"description": info.get("description") or info.get("name") or "",
})
methods.append(PaymentMethodResponse(
id="platega",
name="Platega",
name=settings.get_platega_display_name(),
description="Pay via Platega",
min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS,
is_available=True,
options=platega_options if platega_options else None,
))
# Wata
@@ -198,9 +223,124 @@ async def get_payment_methods():
is_available=True,
))
# CloudPayments
if settings.is_cloudpayments_enabled():
methods.append(PaymentMethodResponse(
id="cloudpayments",
name="CloudPayments",
description="Pay with bank card via CloudPayments",
min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS,
is_available=True,
))
# FreeKassa
if settings.is_freekassa_enabled():
methods.append(PaymentMethodResponse(
id="freekassa",
name=settings.get_freekassa_display_name(),
description="Pay via FreeKassa",
min_amount_kopeks=settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
is_available=True,
))
return methods
@router.post("/stars-invoice", response_model=StarsInvoiceResponse)
async def create_stars_invoice(
request: StarsInvoiceRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Создать Telegram Stars invoice для пополнения баланса.
Используется в Telegram Mini App для прямой оплаты Stars.
"""
if not settings.TELEGRAM_STARS_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Telegram Stars payments are not enabled",
)
# Validate amount
if request.amount_kopeks < 100:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Minimum amount is 1.00 RUB",
)
if request.amount_kopeks > 1000000:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Maximum amount is 10,000.00 RUB",
)
# Calculate Stars amount
try:
amount_rubles = request.amount_kopeks / 100
stars_amount = settings.rubles_to_stars(amount_rubles)
if stars_amount <= 0:
stars_amount = 1
except Exception as e:
logger.error(f"Error calculating Stars amount: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to calculate Stars amount",
)
# Create payload for tracking payment
payload = f"balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}"
# Create invoice through Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f"https://api.telegram.org/bot{bot_token}/createInvoiceLink"
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
"title": "Пополнение баланса VPN",
"description": f"Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)",
"payload": payload,
"provider_token": "", # Empty for Stars
"currency": "XTR",
"prices": [{"label": "Пополнение баланса", "amount": stars_amount}],
},
)
result = response.json()
if not result.get("ok"):
logger.error(f"Telegram API error: {result}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create Stars invoice",
)
invoice_url = result["result"]
logger.info(
f"Created Stars invoice for balance top-up: user={user.id}, "
f"amount={request.amount_kopeks} kopeks, stars={stars_amount}"
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
amount_kopeks=request.amount_kopeks,
)
except httpx.HTTPError as e:
logger.error(f"HTTP error creating Stars invoice: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to connect to Telegram API",
)
@router.post("/topup", response_model=TopUpResponse)
async def create_topup(
request: TopUpRequest,
@@ -244,6 +384,8 @@ async def create_topup(
description=f"Пополнение баланса на {amount_rubles:.2f}",
metadata={
"user_id": str(user.id),
"user_telegram_id": str(user.telegram_id) if user.telegram_id else "",
"user_username": user.username or "",
"amount_kopeks": str(request.amount_kopeks),
"type": "balance_topup",
"source": "cabinet",
@@ -269,7 +411,13 @@ async def create_topup(
payload=f"cabinet_topup_{user.id}_{request.amount_kopeks}",
)
if result:
payment_url = result.get("pay_url") or result.get("bot_invoice_url")
# Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback
payment_url = (
result.get("web_app_invoice_url")
or result.get("mini_app_invoice_url")
or result.get("bot_invoice_url")
or result.get("pay_url")
)
payment_id = str(result.get("invoice_id"))
else:
raise HTTPException(
@@ -320,7 +468,7 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(request.amount_kopeks, telegram_user_id=user.telegram_id),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_code=method_code,
)
@@ -334,6 +482,176 @@ async def create_topup(
detail="Failed to create Platega payment",
)
elif request.payment_method == "heleket":
if not settings.is_heleket_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Heleket payment method is unavailable",
)
payment_service = PaymentService()
result = await payment_service.create_heleket_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
if result and result.get("payment_url"):
payment_url = result.get("payment_url")
payment_id = str(result.get("local_payment_id") or result.get("uuid") or "pending")
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create Heleket payment",
)
elif request.payment_method == "mulenpay":
if not settings.is_mulenpay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="MulenPay payment method is unavailable",
)
payment_service = PaymentService()
result = await payment_service.create_mulenpay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
if result and result.get("payment_url"):
payment_url = result.get("payment_url")
payment_id = str(result.get("local_payment_id") or result.get("mulen_payment_id") or "pending")
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create MulenPay payment",
)
elif request.payment_method == "pal24":
if not settings.is_pal24_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="PAL24 payment method is unavailable",
)
# Use payment_option to select card or sbp (default: sbp)
option = (request.payment_option or "").strip().lower()
if option not in {"card", "sbp"}:
option = "sbp"
provider_method = "card" if option == "card" else "sbp"
payment_service = PaymentService()
result = await payment_service.create_pal24_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method=provider_method,
)
if result:
# Select appropriate URL based on payment option
preferred_urls = []
if option == "sbp":
preferred_urls.append(result.get("sbp_url") or result.get("transfer_url"))
elif option == "card":
preferred_urls.append(result.get("card_url"))
preferred_urls.extend([
result.get("link_url"),
result.get("link_page_url"),
result.get("payment_url"),
result.get("transfer_url"),
])
payment_url = next((url for url in preferred_urls if url), None)
payment_id = str(result.get("local_payment_id") or result.get("bill_id") or "pending")
if not payment_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create PAL24 payment",
)
elif request.payment_method == "wata":
if not settings.is_wata_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Wata payment method is unavailable",
)
payment_service = PaymentService()
result = await payment_service.create_wata_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
if result and result.get("payment_url"):
payment_url = result.get("payment_url")
payment_id = str(result.get("local_payment_id") or result.get("payment_link_id") or "pending")
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create Wata payment",
)
elif request.payment_method == "cloudpayments":
if not settings.is_cloudpayments_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="CloudPayments payment method is unavailable",
)
payment_service = PaymentService()
result = await payment_service.create_cloudpayments_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
if result and result.get("payment_url"):
payment_url = result.get("payment_url")
payment_id = str(result.get("local_payment_id") or result.get("invoice_id") or "pending")
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create CloudPayments payment",
)
elif request.payment_method == "freekassa":
if not settings.is_freekassa_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="FreeKassa payment method is unavailable",
)
payment_service = PaymentService()
result = await payment_service.create_freekassa_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
if result and result.get("payment_url"):
payment_url = result.get("payment_url")
payment_id = str(result.get("local_payment_id") or result.get("order_id") or "pending")
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create FreeKassa payment",
)
else:
# For other payment methods, redirect to bot
raise HTTPException(
+234 -9
View File
@@ -1,8 +1,8 @@
"""Branding routes for cabinet - logo and project name management."""
"""Branding routes for cabinet - logo, project name, and theme colors management."""
import logging
import os
import base64
import json
from pathlib import Path
from typing import Optional
@@ -23,15 +23,17 @@ router = APIRouter(prefix="/branding", tags=["Branding"])
# Directory for storing branding assets
BRANDING_DIR = Path("data/branding")
LOGO_FILENAME = "logo.png"
LOGO_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".svg"]
# Settings keys
BRANDING_NAME_KEY = "CABINET_BRANDING_NAME"
BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default"
THEME_COLORS_KEY = "CABINET_THEME_COLORS" # Stores JSON with theme colors
ENABLED_THEMES_KEY = "CABINET_ENABLED_THEMES" # Stores JSON with enabled themes {"dark": true, "light": false}
# Allowed image types
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"}
MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB for larger logos
# ============ Schemas ============
@@ -49,6 +51,67 @@ class BrandingNameUpdate(BaseModel):
name: str
class ThemeColorsResponse(BaseModel):
"""Theme colors settings."""
accent: str = "#3b82f6"
darkBackground: str = "#0a0f1a"
darkSurface: str = "#0f172a"
darkText: str = "#f1f5f9"
darkTextSecondary: str = "#94a3b8"
lightBackground: str = "#F7E7CE"
lightSurface: str = "#FEF9F0"
lightText: str = "#1F1A12"
lightTextSecondary: str = "#7D6B48"
success: str = "#22c55e"
warning: str = "#f59e0b"
error: str = "#ef4444"
class ThemeColorsUpdate(BaseModel):
"""Request to update theme colors (partial update allowed)."""
accent: Optional[str] = None
darkBackground: Optional[str] = None
darkSurface: Optional[str] = None
darkText: Optional[str] = None
darkTextSecondary: Optional[str] = None
lightBackground: Optional[str] = None
lightSurface: Optional[str] = None
lightText: Optional[str] = None
lightTextSecondary: Optional[str] = None
success: Optional[str] = None
warning: Optional[str] = None
error: Optional[str] = None
class EnabledThemesResponse(BaseModel):
"""Enabled themes settings."""
dark: bool = True
light: bool = True
class EnabledThemesUpdate(BaseModel):
"""Request to update enabled themes."""
dark: Optional[bool] = None
light: Optional[bool] = None
# Default theme colors
DEFAULT_THEME_COLORS = {
"accent": "#3b82f6",
"darkBackground": "#0a0f1a",
"darkSurface": "#0f172a",
"darkText": "#f1f5f9",
"darkTextSecondary": "#94a3b8",
"lightBackground": "#F7E7CE",
"lightSurface": "#FEF9F0",
"lightText": "#1F1A12",
"lightTextSecondary": "#7D6B48",
"success": "#22c55e",
"warning": "#f59e0b",
"error": "#ef4444",
}
# ============ Helper Functions ============
def ensure_branding_dir():
@@ -81,14 +144,23 @@ async def set_setting_value(db: AsyncSession, key: str, value: str):
await db.commit()
def get_logo_path() -> Path:
"""Get the path to the custom logo file."""
return BRANDING_DIR / LOGO_FILENAME
def get_logo_path() -> Optional[Path]:
"""Get the path to the custom logo file (any supported format)."""
if not BRANDING_DIR.exists():
return None
# Search for logo file with any supported extension
for ext in LOGO_EXTENSIONS:
logo_path = BRANDING_DIR / f"logo{ext}"
if logo_path.exists():
return logo_path
return None
def has_custom_logo() -> bool:
"""Check if a custom logo exists."""
return get_logo_path().exists()
return get_logo_path() is not None
# ============ Routes ============
@@ -129,7 +201,7 @@ async def get_logo():
"""
logo_path = get_logo_path()
if not logo_path.exists():
if logo_path is None or not logo_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No custom logo set"
@@ -279,3 +351,156 @@ async def delete_logo(
logo_letter=logo_letter,
has_custom_logo=False,
)
# ============ Theme Colors Routes ============
def validate_hex_color(color: str) -> bool:
"""Validate hex color format."""
if not color or not isinstance(color, str):
return False
if not color.startswith("#"):
return False
hex_part = color[1:]
if len(hex_part) not in (3, 6):
return False
try:
int(hex_part, 16)
return True
except ValueError:
return False
@router.get("/colors", response_model=ThemeColorsResponse)
async def get_theme_colors(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get current theme colors.
This is a public endpoint - no authentication required.
"""
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
if colors_json:
try:
colors = json.loads(colors_json)
# Merge with defaults to ensure all fields exist
merged = {**DEFAULT_THEME_COLORS, **colors}
return ThemeColorsResponse(**merged)
except (json.JSONDecodeError, TypeError):
pass
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
@router.patch("/colors", response_model=ThemeColorsResponse)
async def update_theme_colors(
payload: ThemeColorsUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update theme colors. Admin only. Partial update supported."""
# Get current colors
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
current_colors = DEFAULT_THEME_COLORS.copy()
if colors_json:
try:
current_colors.update(json.loads(colors_json))
except (json.JSONDecodeError, TypeError):
pass
# Update with new values (only non-None fields)
update_data = payload.model_dump(exclude_none=True)
# Validate hex colors
for key, value in update_data.items():
if not validate_hex_color(value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid hex color for {key}: {value}"
)
current_colors.update(update_data)
# Save to database
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors))
logger.info(f"Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}")
return ThemeColorsResponse(**current_colors)
@router.post("/colors/reset", response_model=ThemeColorsResponse)
async def reset_theme_colors(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset theme colors to defaults. Admin only."""
# Save default colors
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
logger.info(f"Admin {admin.telegram_id} reset theme colors to defaults")
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
# ============ Enabled Themes Routes ============
DEFAULT_ENABLED_THEMES = {"dark": True, "light": True}
@router.get("/themes", response_model=EnabledThemesResponse)
async def get_enabled_themes(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get which themes are enabled.
This is a public endpoint - no authentication required.
"""
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
if themes_json:
try:
themes = json.loads(themes_json)
return EnabledThemesResponse(**themes)
except (json.JSONDecodeError, TypeError):
pass
return EnabledThemesResponse(**DEFAULT_ENABLED_THEMES)
@router.patch("/themes", response_model=EnabledThemesResponse)
async def update_enabled_themes(
payload: EnabledThemesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
# Get current settings
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
current_themes = DEFAULT_ENABLED_THEMES.copy()
if themes_json:
try:
current_themes.update(json.loads(themes_json))
except (json.JSONDecodeError, TypeError):
pass
# Update with new values
update_data = payload.model_dump(exclude_none=True)
current_themes.update(update_data)
# Ensure at least one theme is enabled
if not current_themes.get("dark") and not current_themes.get("light"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one theme must be enabled"
)
# Save to database
await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes))
logger.info(f"Admin {admin.telegram_id} updated enabled themes: {current_themes}")
return EnabledThemesResponse(**current_themes)
+36
View File
@@ -59,6 +59,14 @@ class ServiceInfoResponse(BaseModel):
website: Optional[str] = None
class SupportConfigResponse(BaseModel):
"""Support/tickets configuration for miniapp."""
tickets_enabled: bool
support_type: str # "tickets", "profile", "url"
support_url: Optional[str] = None
support_username: Optional[str] = None
# ============ Routes ============
@router.get("/faq", response_model=List[FaqPageResponse])
@@ -235,3 +243,31 @@ async def update_user_language(
await db.refresh(user)
return {"language": user.language}
@router.get("/support-config", response_model=SupportConfigResponse)
async def get_support_config():
"""Get support/tickets configuration for cabinet."""
# Use SUPPORT_SYSTEM_MODE setting (configurable from admin panel)
support_mode = settings.get_support_system_mode() # returns: tickets, contact, or both
# Map support mode to support type for frontend
# - "tickets" mode -> tickets only, no contact
# - "contact" mode -> contact only (profile), no tickets
# - "both" mode -> tickets enabled, contact available as fallback
if support_mode == "tickets":
tickets_enabled = True
support_type = "tickets"
elif support_mode == "contact":
tickets_enabled = False
support_type = "profile"
else: # both
tickets_enabled = True
support_type = "tickets"
return SupportConfigResponse(
tickets_enabled=tickets_enabled,
support_type=support_type,
support_url=None, # Cabinet doesn't use custom URLs
support_username=settings.SUPPORT_USERNAME, # Always return for fallback
)
+196
View File
@@ -0,0 +1,196 @@
"""Media upload/download routes for cabinet tickets."""
import logging
import mimetypes
from typing import Optional
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/media", tags=["Cabinet Media"])
ALLOWED_MEDIA_TYPES = {"photo", "video", "document"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
class MediaUploadResponse(BaseModel):
"""Response after successful media upload."""
media_type: str
file_id: str
file_unique_id: Optional[str] = None
media_url: str
def _resolve_target_chat_id() -> int:
"""Get chat ID for uploading files (notification channel or first admin)."""
chat_id = settings.get_admin_notifications_chat_id()
if chat_id is not None:
return chat_id
admin_ids = settings.get_admin_ids()
if admin_ids:
return admin_ids[0]
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No chat configured for file uploads",
)
def _build_media_url(request: Request, file_id: str) -> str:
"""Build URL for downloading media."""
return str(request.url_for("cabinet_download_media", file_id=file_id))
@router.post("/upload", response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_media(
request: Request,
user: User = Depends(get_current_cabinet_user),
file: UploadFile = File(...),
media_type: str = Form("photo", description="File type: photo, video, or document"),
):
"""
Upload media file for use in ticket messages.
Returns file_id that can be used when creating ticket or adding message.
"""
media_type_normalized = (media_type or "").strip().lower()
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported media type. Allowed: {', '.join(ALLOWED_MEDIA_TYPES)}",
)
# Read and validate file
file_bytes = await file.read()
if not file_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File is empty",
)
if len(file_bytes) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB",
)
# Validate content type for photos
if media_type_normalized == "photo":
allowed_image_types = {"image/jpeg", "image/png", "image/gif", "image/webp"}
if file.content_type and file.content_type not in allowed_image_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid image type. Allowed: JPEG, PNG, GIF, WebP",
)
target_chat_id = _resolve_target_chat_id()
upload = BufferedInputFile(file_bytes, filename=file.filename or "upload")
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
if media_type_normalized == "photo":
message = await bot.send_photo(
chat_id=target_chat_id,
photo=upload,
)
media = message.photo[-1]
elif media_type_normalized == "video":
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
)
media = message.video
else:
message = await bot.send_document(
chat_id=target_chat_id,
document=upload,
)
media = message.document
media_url = _build_media_url(request, media.file_id)
logger.info(f"User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}")
return MediaUploadResponse(
media_type=media_type_normalized,
file_id=media.file_id,
file_unique_id=getattr(media, "file_unique_id", None),
media_url=media_url,
)
except HTTPException:
raise
except Exception as error:
logger.error(f"Failed to upload media for user {user.telegram_id}: {error}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to upload media",
) from error
finally:
await bot.session.close()
@router.get("/{file_id}", name="cabinet_download_media")
async def download_media(
file_id: str,
) -> Response:
"""
Download media file by file_id.
Used to display images/documents in ticket messages.
"""
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
file = await bot.get_file(file_id)
if not file.file_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Media file not found",
)
buffer = await bot.download_file(file.file_path)
if hasattr(buffer, "seek"):
buffer.seek(0)
content = buffer.read() if hasattr(buffer, "read") else bytes(buffer)
filename = file.file_path.split("/")[-1]
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return Response(
content=content,
media_type=media_type,
headers={
"Content-Disposition": f"inline; filename={filename}",
"Cache-Control": "public, max-age=86400", # Cache for 24 hours
},
)
except HTTPException:
raise
except Exception as error:
logger.error(f"Failed to download media {file_id}: {error}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to download media",
) from error
finally:
await bot.session.close()
+3 -2
View File
@@ -7,6 +7,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from app.database.models import User, ReferralEarning
from app.config import settings
@@ -83,8 +84,8 @@ async def get_referral_list(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of invited users."""
# Base query
query = select(User).where(User.referred_by_id == user.id)
# Base query with eager loading of subscription relationship
query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user.id)
# Get total count
count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -36,6 +36,7 @@ def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
created_at=message.created_at,
)
@@ -143,12 +144,15 @@ async def create_ticket(
db.add(ticket)
await db.flush()
# Create initial message
# Create initial message with optional media
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
)
db.add(message)
@@ -243,12 +247,15 @@ async def add_ticket_message(
detail="Replies to this ticket are blocked",
)
# Create message
# Create message with optional media
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
)
db.add(message)
+271
View File
@@ -0,0 +1,271 @@
"""
API роуты колеса удачи для пользователей.
"""
import logging
import math
import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional
from app.config import settings
from app.database.models import User
from app.database.crud.wheel import (
get_or_create_wheel_config,
get_wheel_prizes,
get_user_spins_today,
get_user_spin_history,
)
from app.services.wheel_service import wheel_service
from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user
from app.cabinet.schemas.wheel import (
WheelConfigResponse,
WheelPrizeDisplay,
SpinAvailabilityResponse,
SpinRequest,
SpinResultResponse,
SpinHistoryResponse,
SpinHistoryItem,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/wheel", tags=["Fortune Wheel"])
@router.get("/config", response_model=WheelConfigResponse)
async def get_wheel_config(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить конфигурацию колеса удачи."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
spins_today = await get_user_spins_today(db, user.id)
# Проверяем доступность
availability = await wheel_service.check_availability(db, user)
prizes_display = [
WheelPrizeDisplay(
id=p.id,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_type=p.prize_type,
)
for p in prizes
]
return WheelConfigResponse(
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars if config.spin_cost_stars_enabled else None,
spin_cost_days=config.spin_cost_days if config.spin_cost_days_enabled else None,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
prizes=prizes_display,
daily_limit=config.daily_spin_limit,
user_spins_today=spins_today,
can_spin=availability.can_spin,
can_spin_reason=availability.reason,
can_pay_stars=availability.can_pay_stars,
can_pay_days=availability.can_pay_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
)
@router.get("/availability", response_model=SpinAvailabilityResponse)
async def check_spin_availability(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Проверить доступность спина."""
availability = await wheel_service.check_availability(db, user)
return SpinAvailabilityResponse(
can_spin=availability.can_spin,
reason=availability.reason,
spins_remaining_today=availability.spins_remaining_today,
can_pay_stars=availability.can_pay_stars,
can_pay_days=availability.can_pay_days,
min_subscription_days=availability.min_subscription_days,
user_subscription_days=availability.user_subscription_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
)
@router.post("/spin", response_model=SpinResultResponse)
async def spin_wheel(
request: SpinRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Крутить колесо удачи."""
result = await wheel_service.spin(db, user, request.payment_type.value)
if not result.success:
# Возвращаем ошибку в теле ответа, а не HTTP exception
return SpinResultResponse(
success=False,
error=result.error,
message=result.message,
)
return SpinResultResponse(
success=True,
prize_id=result.prize_id,
prize_type=result.prize_type,
prize_value=result.prize_value,
prize_display_name=result.prize_display_name,
emoji=result.emoji,
color=result.color,
rotation_degrees=result.rotation_degrees,
message=result.message,
promocode=result.promocode,
)
@router.get("/history", response_model=SpinHistoryResponse)
async def get_spin_history(
page: int = 1,
per_page: int = 20,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить историю спинов пользователя."""
if page < 1:
page = 1
if per_page < 1 or per_page > 100:
per_page = 20
offset = (page - 1) * per_page
spins, total = await get_user_spin_history(db, user.id, limit=per_page, offset=offset)
items = []
for spin in spins:
# Получаем emoji и color из приза, если он есть
emoji = "🎁"
color = "#3B82F6"
if spin.prize:
emoji = spin.prize.emoji
color = spin.prize.color
items.append(SpinHistoryItem(
id=spin.id,
payment_type=spin.payment_type,
payment_amount=spin.payment_amount,
prize_type=spin.prize_type,
prize_value=spin.prize_value,
prize_display_name=spin.prize_display_name,
emoji=emoji,
color=color,
prize_value_kopeks=spin.prize_value_kopeks,
created_at=spin.created_at,
))
pages = math.ceil(total / per_page) if total > 0 else 1
return SpinHistoryResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
class StarsInvoiceResponse(BaseModel):
"""Ответ с ссылкой на Stars invoice."""
invoice_url: str
stars_amount: int
@router.post("/stars-invoice", response_model=StarsInvoiceResponse)
async def create_stars_invoice(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Создать Telegram Stars invoice для оплаты спина колеса.
Используется в Telegram Mini App для прямой оплаты Stars.
"""
config = await get_or_create_wheel_config(db)
if not config.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Колесо удачи недоступно",
)
if not config.spin_cost_stars_enabled or not config.spin_cost_stars:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Оплата Stars не включена",
)
# Проверяем лимит спинов
spins_today = await get_user_spins_today(db, user.id)
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Достигнут дневной лимит спинов",
)
# Проверяем наличие призов
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Призы не настроены",
)
stars_amount = config.spin_cost_stars
payload = f"wheel_spin_{user.id}_{int(time.time())}"
# Создаем invoice через Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f"https://api.telegram.org/bot{bot_token}/createInvoiceLink"
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
"title": "Колесо удачи",
"description": f"Спин колеса удачи ({stars_amount} ⭐)",
"payload": payload,
"provider_token": "", # Пустой для Stars
"currency": "XTR",
"prices": [{"label": "Спин колеса", "amount": stars_amount}],
},
)
result = response.json()
if not result.get("ok"):
logger.error(f"Telegram API error: {result}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Ошибка создания инвойса",
)
invoice_url = result["result"]
logger.info(f"Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}")
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
)
except httpx.HTTPError as e:
logger.error(f"HTTP error creating invoice: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Ошибка соединения с Telegram",
)
+2
View File
@@ -15,6 +15,7 @@ from .auth import (
)
from .subscription import (
SubscriptionResponse,
TrafficPurchaseInfo,
RenewalOptionResponse,
RenewalRequest,
TrafficPackageResponse,
@@ -59,6 +60,7 @@ __all__ = [
"AuthResponse",
# Subscription
"SubscriptionResponse",
"TrafficPurchaseInfo",
"RenewalOptionResponse",
"RenewalRequest",
"TrafficPackageResponse",
+21 -1
View File
@@ -1,7 +1,7 @@
"""Balance and payment schemas for cabinet."""
from datetime import datetime
from typing import Optional, List
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
@@ -36,6 +36,13 @@ class TransactionListResponse(BaseModel):
pages: int
class PaymentOptionResponse(BaseModel):
"""Payment method option (e.g. Platega sub-methods)."""
id: str
name: str
description: Optional[str] = None
class PaymentMethodResponse(BaseModel):
"""Available payment method."""
id: str
@@ -44,6 +51,7 @@ class PaymentMethodResponse(BaseModel):
min_amount_kopeks: int
max_amount_kopeks: int
is_available: bool = True
options: Optional[List[Dict[str, Any]]] = None
class TopUpRequest(BaseModel):
@@ -61,3 +69,15 @@ class TopUpResponse(BaseModel):
amount_rubles: float
status: str
expires_at: Optional[datetime] = None
class StarsInvoiceRequest(BaseModel):
"""Request to create Telegram Stars invoice for balance top-up."""
amount_kopeks: int = Field(..., ge=100, description="Amount in kopeks (min 1 ruble)")
class StarsInvoiceResponse(BaseModel):
"""Response with Telegram Stars invoice link."""
invoice_url: str
stars_amount: int
amount_kopeks: int
+119
View File
@@ -0,0 +1,119 @@
"""Schemas for server management in cabinet."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class PromoGroupInfo(BaseModel):
"""Promo group info for server."""
id: int
name: str
is_selected: bool = False
class ServerListItem(BaseModel):
"""Server item for list view."""
id: int
squad_uuid: str
display_name: str
original_name: Optional[str] = None
country_code: Optional[str] = None
is_available: bool
is_trial_eligible: bool
price_kopeks: int
price_rubles: float
max_users: Optional[int] = None
current_users: int
sort_order: int
is_full: bool
availability_status: str
created_at: datetime
class Config:
from_attributes = True
class ServerListResponse(BaseModel):
"""Response with list of servers."""
servers: List[ServerListItem]
total: int
class ServerDetailResponse(BaseModel):
"""Detailed server response."""
id: int
squad_uuid: str
display_name: str
original_name: Optional[str] = None
country_code: Optional[str] = None
description: Optional[str] = None
is_available: bool
is_trial_eligible: bool
price_kopeks: int
price_rubles: float
max_users: Optional[int] = None
current_users: int
sort_order: int
is_full: bool
availability_status: str
promo_groups: List[PromoGroupInfo]
active_subscriptions: int
tariffs_using: List[str] # Names of tariffs using this server
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class ServerUpdateRequest(BaseModel):
"""Request to update a server."""
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
country_code: Optional[str] = Field(None, max_length=5)
is_available: Optional[bool] = None
is_trial_eligible: Optional[bool] = None
price_kopeks: Optional[int] = Field(None, ge=0)
max_users: Optional[int] = Field(None, ge=0)
sort_order: Optional[int] = Field(None, ge=0)
promo_group_ids: Optional[List[int]] = None
class ServerToggleResponse(BaseModel):
"""Response after toggling server."""
id: int
is_available: bool
message: str
class ServerTrialToggleResponse(BaseModel):
"""Response after toggling trial eligibility."""
id: int
is_trial_eligible: bool
message: str
class ServerStatsResponse(BaseModel):
"""Server statistics."""
id: int
display_name: str
squad_uuid: str
current_users: int
max_users: Optional[int]
active_subscriptions: int
trial_subscriptions: int
usage_percent: Optional[float] = None
class ServerSyncResponse(BaseModel):
"""Response after syncing with RemnaWave."""
created: int
updated: int
removed: int
message: str
class ServerSyncRequest(BaseModel):
"""Request to sync servers."""
force: bool = False # Force sync even if recently synced
+27
View File
@@ -12,6 +12,16 @@ class ServerInfo(BaseModel):
country_code: Optional[str] = None
class TrafficPurchaseInfo(BaseModel):
"""Purchased traffic package info."""
id: int
traffic_gb: int
expires_at: datetime
created_at: datetime
days_remaining: int
progress_percent: float
class SubscriptionResponse(BaseModel):
"""User subscription data."""
id: int
@@ -34,6 +44,14 @@ class SubscriptionResponse(BaseModel):
subscription_url: Optional[str] = None
is_active: bool
is_expired: bool
traffic_purchases: List[TrafficPurchaseInfo] = []
# Daily tariff fields
is_daily: bool = False
is_daily_paused: bool = False
daily_price_kopeks: Optional[int] = None
next_daily_charge_at: Optional[datetime] = None # When next daily charge will happen
tariff_id: Optional[int] = None
tariff_name: Optional[str] = None
class Config:
from_attributes = True
@@ -103,3 +121,12 @@ class PurchaseSelectionRequest(BaseModel):
class PurchasePreviewRequest(BaseModel):
"""Request to preview purchase pricing."""
selection: PurchaseSelectionRequest
# ============ Tariff Purchase Schemas ============
class TariffPurchaseRequest(BaseModel):
"""Request to purchase a tariff."""
tariff_id: int = Field(..., description="Tariff ID to purchase")
period_days: int = Field(..., description="Period in days")
traffic_gb: Optional[int] = Field(None, ge=0, description="Custom traffic in GB (for custom_traffic_enabled tariffs)")
+208
View File
@@ -0,0 +1,208 @@
"""Schemas for tariff management in cabinet."""
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
class PeriodPrice(BaseModel):
"""Price for a specific period."""
days: int = Field(..., ge=1, description="Period in days")
price_kopeks: int = Field(..., ge=0, description="Price in kopeks")
price_rubles: Optional[float] = None
def __init__(self, **data):
super().__init__(**data)
if self.price_rubles is None:
self.price_rubles = self.price_kopeks / 100
class ServerTrafficLimit(BaseModel):
"""Traffic limit for a specific server."""
traffic_limit_gb: int = Field(0, ge=0, description="0 = use default tariff limit")
class ServerInfo(BaseModel):
"""Server info for tariff."""
id: int
squad_uuid: str
display_name: str
country_code: Optional[str] = None
is_selected: bool = False
traffic_limit_gb: Optional[int] = None # Индивидуальный лимит для сервера
class PromoGroupInfo(BaseModel):
"""Promo group info for tariff."""
id: int
name: str
is_selected: bool = False
class TariffListItem(BaseModel):
"""Tariff item for list view."""
id: int
name: str
description: Optional[str] = None
is_active: bool
is_trial_available: bool
is_daily: bool = False
daily_price_kopeks: int = 0
allow_traffic_topup: bool = True
traffic_limit_gb: int
device_limit: int
tier_level: int
display_order: int
servers_count: int
subscriptions_count: int
created_at: datetime
class Config:
from_attributes = True
class TariffListResponse(BaseModel):
"""Response with list of tariffs."""
tariffs: List[TariffListItem]
total: int
class TariffDetailResponse(BaseModel):
"""Detailed tariff response."""
id: int
name: str
description: Optional[str] = None
is_active: bool
is_trial_available: bool
allow_traffic_topup: bool = True
traffic_topup_enabled: bool = False
traffic_topup_packages: Dict[str, int] = Field(default_factory=dict)
max_topup_traffic_gb: int = 0
traffic_limit_gb: int
device_limit: int
device_price_kopeks: Optional[int] = None
max_device_limit: Optional[int] = None
tier_level: int
display_order: int
period_prices: List[PeriodPrice]
allowed_squads: List[str] # UUIDs
server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict) # {uuid: {traffic_limit_gb}}
servers: List[ServerInfo]
promo_groups: List[PromoGroupInfo]
subscriptions_count: int
# Произвольное количество дней
custom_days_enabled: bool = False
price_per_day_kopeks: int = 0
min_days: int = 1
max_days: int = 365
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False
traffic_price_per_gb_kopeks: int = 0
min_traffic_gb: int = 1
max_traffic_gb: int = 1000
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = 0
# Режим сброса трафика
traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class TariffCreateRequest(BaseModel):
"""Request to create a tariff."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
is_active: bool = True
allow_traffic_topup: bool = True
traffic_topup_enabled: bool = False
traffic_topup_packages: Dict[str, int] = Field(default_factory=dict)
max_topup_traffic_gb: int = Field(0, ge=0)
traffic_limit_gb: int = Field(0, ge=0, description="0 = unlimited")
device_limit: int = Field(1, ge=1)
device_price_kopeks: Optional[int] = Field(None, ge=0)
max_device_limit: Optional[int] = Field(None, ge=1)
tier_level: int = Field(1, ge=1, le=10)
period_prices: List[PeriodPrice] = Field(default_factory=list)
allowed_squads: List[str] = Field(default_factory=list, description="Server UUIDs")
server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict, description="Per-server traffic limits")
promo_group_ids: List[int] = Field(default_factory=list)
# Произвольное количество дней
custom_days_enabled: bool = False
price_per_day_kopeks: int = Field(0, ge=0)
min_days: int = Field(1, ge=1)
max_days: int = Field(365, ge=1)
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False
traffic_price_per_gb_kopeks: int = Field(0, ge=0)
min_traffic_gb: int = Field(1, ge=1)
max_traffic_gb: int = Field(1000, ge=1)
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = Field(0, ge=0)
# Режим сброса трафика
traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffUpdateRequest(BaseModel):
"""Request to update a tariff."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
is_active: Optional[bool] = None
allow_traffic_topup: Optional[bool] = None
traffic_topup_enabled: Optional[bool] = None
traffic_topup_packages: Optional[Dict[str, int]] = None
max_topup_traffic_gb: Optional[int] = Field(None, ge=0)
traffic_limit_gb: Optional[int] = Field(None, ge=0)
device_limit: Optional[int] = Field(None, ge=1)
device_price_kopeks: Optional[int] = Field(None, ge=0)
max_device_limit: Optional[int] = Field(None, ge=1)
tier_level: Optional[int] = Field(None, ge=1, le=10)
display_order: Optional[int] = Field(None, ge=0)
period_prices: Optional[List[PeriodPrice]] = None
allowed_squads: Optional[List[str]] = None
server_traffic_limits: Optional[Dict[str, ServerTrafficLimit]] = None
promo_group_ids: Optional[List[int]] = None
# Произвольное количество дней
custom_days_enabled: Optional[bool] = None
price_per_day_kopeks: Optional[int] = Field(None, ge=0)
min_days: Optional[int] = Field(None, ge=1)
max_days: Optional[int] = Field(None, ge=1)
# Произвольный трафик при покупке
custom_traffic_enabled: Optional[bool] = None
traffic_price_per_gb_kopeks: Optional[int] = Field(None, ge=0)
min_traffic_gb: Optional[int] = Field(None, ge=1)
max_traffic_gb: Optional[int] = Field(None, ge=1)
# Дневной тариф
is_daily: Optional[bool] = None
daily_price_kopeks: Optional[int] = Field(None, ge=0)
# Режим сброса трафика
traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffToggleResponse(BaseModel):
"""Response after toggling tariff."""
id: int
is_active: bool
message: str
class TariffTrialResponse(BaseModel):
"""Response after setting trial tariff."""
id: int
is_trial_available: bool
message: str
class TariffStatsResponse(BaseModel):
"""Tariff statistics."""
id: int
name: str
subscriptions_count: int
active_subscriptions: int
trial_subscriptions: int
revenue_kopeks: int
revenue_rubles: float
+7
View File
@@ -12,6 +12,7 @@ class TicketMessageResponse(BaseModel):
is_from_admin: bool
has_media: bool = False
media_type: Optional[str] = None
media_file_id: Optional[str] = None
media_caption: Optional[str] = None
created_at: datetime
@@ -64,8 +65,14 @@ class TicketCreateRequest(BaseModel):
"""Request to create a new ticket."""
title: str = Field(..., min_length=3, max_length=255, description="Ticket title")
message: str = Field(..., min_length=10, max_length=4000, description="Initial message")
media_type: Optional[str] = Field(None, description="Media type: photo, video, document")
media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media")
media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption")
class TicketMessageCreateRequest(BaseModel):
"""Request to add message to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description="Message text")
media_type: Optional[str] = Field(None, description="Media type: photo, video, document")
media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media")
media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption")
+259
View File
@@ -0,0 +1,259 @@
"""Схемы для колеса удачи (Fortune Wheel)."""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
from enum import Enum
# ==================== ENUMS ====================
class WheelPaymentType(str, Enum):
"""Способы оплаты спина."""
TELEGRAM_STARS = "telegram_stars"
SUBSCRIPTION_DAYS = "subscription_days"
class WheelPrizeType(str, Enum):
"""Типы призов."""
SUBSCRIPTION_DAYS = "subscription_days"
BALANCE_BONUS = "balance_bonus"
TRAFFIC_GB = "traffic_gb"
PROMOCODE = "promocode"
NOTHING = "nothing"
# ==================== USER SCHEMAS ====================
class WheelPrizeDisplay(BaseModel):
"""Отображение приза для пользователя."""
id: int
display_name: str
emoji: str
color: str
prize_type: str
class Config:
from_attributes = True
class WheelConfigResponse(BaseModel):
"""Конфигурация колеса для пользователя."""
is_enabled: bool
name: str
spin_cost_stars: Optional[int] = None
spin_cost_days: Optional[int] = None
spin_cost_stars_enabled: bool
spin_cost_days_enabled: bool
prizes: List[WheelPrizeDisplay]
daily_limit: int
user_spins_today: int
can_spin: bool
can_spin_reason: Optional[str] = None
can_pay_stars: bool = False
can_pay_days: bool = False
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
class SpinAvailabilityResponse(BaseModel):
"""Доступность спина."""
can_spin: bool
reason: Optional[str] = None
spins_remaining_today: int
can_pay_stars: bool
can_pay_days: bool
min_subscription_days: int
user_subscription_days: int
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
class SpinRequest(BaseModel):
"""Запрос на спин."""
payment_type: WheelPaymentType
class SpinResultResponse(BaseModel):
"""Результат спина."""
success: bool
prize_id: Optional[int] = None
prize_type: Optional[str] = None
prize_value: int = 0
prize_display_name: str = ""
emoji: str = "🎁"
color: str = "#3B82F6"
rotation_degrees: float = 0.0
message: str = ""
promocode: Optional[str] = None
error: Optional[str] = None
class SpinHistoryItem(BaseModel):
"""Элемент истории спинов."""
id: int
payment_type: str
payment_amount: int
prize_type: str
prize_value: int
prize_display_name: str
emoji: str = "🎁"
color: str = "#3B82F6"
prize_value_kopeks: int
created_at: datetime
class Config:
from_attributes = True
class SpinHistoryResponse(BaseModel):
"""История спинов с пагинацией."""
items: List[SpinHistoryItem]
total: int
page: int
per_page: int
pages: int
# ==================== ADMIN SCHEMAS ====================
class WheelPrizeAdminResponse(BaseModel):
"""Полная информация о призе для админа."""
id: int
config_id: int
prize_type: str
prize_value: int
display_name: str
emoji: str
color: str
prize_value_kopeks: int
sort_order: int
manual_probability: Optional[float] = None
is_active: bool
promo_balance_bonus_kopeks: int = 0
promo_subscription_days: int = 0
promo_traffic_gb: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class AdminWheelConfigResponse(BaseModel):
"""Полная конфигурация колеса для админа."""
id: int
is_enabled: bool
name: str
spin_cost_stars: int
spin_cost_days: int
spin_cost_stars_enabled: bool
spin_cost_days_enabled: bool
rtp_percent: int
daily_spin_limit: int
min_subscription_days_for_day_payment: int
promo_prefix: str
promo_validity_days: int
prizes: List[WheelPrizeAdminResponse]
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class UpdateWheelConfigRequest(BaseModel):
"""Запрос на обновление конфига колеса."""
is_enabled: Optional[bool] = None
name: Optional[str] = Field(None, min_length=1, max_length=255)
spin_cost_stars: Optional[int] = Field(None, ge=1, le=1000)
spin_cost_days: Optional[int] = Field(None, ge=1, le=30)
spin_cost_stars_enabled: Optional[bool] = None
spin_cost_days_enabled: Optional[bool] = None
rtp_percent: Optional[int] = Field(None, ge=0, le=100)
daily_spin_limit: Optional[int] = Field(None, ge=0, le=100)
min_subscription_days_for_day_payment: Optional[int] = Field(None, ge=1, le=30)
promo_prefix: Optional[str] = Field(None, min_length=1, max_length=20)
promo_validity_days: Optional[int] = Field(None, ge=1, le=365)
class CreatePrizeRequest(BaseModel):
"""Запрос на создание приза."""
prize_type: WheelPrizeType
prize_value: int = Field(..., ge=0)
display_name: str = Field(..., min_length=1, max_length=100)
emoji: str = Field(default="🎁", max_length=10)
color: str = Field(default="#3B82F6", pattern=r'^#[0-9A-Fa-f]{6}$')
prize_value_kopeks: int = Field(..., ge=0)
sort_order: int = Field(default=0, ge=0)
manual_probability: Optional[float] = Field(None, ge=0, le=1)
is_active: bool = True
promo_balance_bonus_kopeks: int = Field(default=0, ge=0)
promo_subscription_days: int = Field(default=0, ge=0)
promo_traffic_gb: int = Field(default=0, ge=0)
class UpdatePrizeRequest(BaseModel):
"""Запрос на обновление приза."""
prize_type: Optional[WheelPrizeType] = None
prize_value: Optional[int] = Field(None, ge=0)
display_name: Optional[str] = Field(None, min_length=1, max_length=100)
emoji: Optional[str] = Field(None, max_length=10)
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
prize_value_kopeks: Optional[int] = Field(None, ge=0)
sort_order: Optional[int] = Field(None, ge=0)
manual_probability: Optional[float] = Field(None, ge=0, le=1)
is_active: Optional[bool] = None
promo_balance_bonus_kopeks: Optional[int] = Field(None, ge=0)
promo_subscription_days: Optional[int] = Field(None, ge=0)
promo_traffic_gb: Optional[int] = Field(None, ge=0)
class ReorderPrizesRequest(BaseModel):
"""Запрос на переупорядочивание призов."""
prize_ids: List[int]
class AdminSpinItem(BaseModel):
"""Спин для админки."""
id: int
user_id: int
username: Optional[str] = None
payment_type: str
payment_amount: int
payment_value_kopeks: int
prize_type: str
prize_value: int
prize_display_name: str
prize_value_kopeks: int
is_applied: bool
created_at: datetime
class Config:
from_attributes = True
class AdminSpinsResponse(BaseModel):
"""Список спинов для админки с пагинацией."""
items: List[AdminSpinItem]
total: int
page: int
per_page: int
pages: int
class WheelStatisticsResponse(BaseModel):
"""Статистика колеса."""
total_spins: int
total_revenue_kopeks: int
total_payout_kopeks: int
actual_rtp_percent: float
configured_rtp_percent: int
spins_by_payment_type: dict
prizes_distribution: List[dict]
top_wins: List[dict]
period_from: Optional[str] = None
period_to: Optional[str] = None
+226 -25
View File
@@ -15,9 +15,9 @@ from pydantic import field_validator, Field
from pathlib import Path
DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
"tme",
"joingroup",
DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS: list[str] = [
# Пустой по умолчанию - администратор может добавить ключевые слова через DISPLAY_NAME_BANNED_KEYWORDS
# Примеры: "tme", "joingroup", "support", "admin"
]
USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$")
@@ -41,6 +41,11 @@ class Settings(BaseSettings):
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15
# MiniApp tickets settings
MINIAPP_TICKETS_ENABLED: bool = True # Enable/disable tickets section in miniapp
MINIAPP_SUPPORT_TYPE: str = "tickets" # one of: tickets, profile, url
MINIAPP_SUPPORT_URL: str = "" # Custom URL to redirect when tickets disabled (only for url type)
ADMIN_NOTIFICATIONS_ENABLED: bool = False
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
@@ -94,6 +99,7 @@ class Settings(BaseSettings):
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
REMNAWAVE_AUTO_SYNC_ENABLED: bool = False
REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00"
CABINET_REMNA_SUB_CONFIG: Optional[str] = None # UUID конфига страницы подписки из RemnaWave
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
@@ -227,7 +233,7 @@ class Settings(BaseSettings):
BLACKLIST_IGNORE_ADMINS: bool = True
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = True
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
@@ -262,6 +268,9 @@ class Settings(BaseSettings):
TRAFFIC_CHECK_CONCURRENCY: int = 10 # Параллельных запросов
TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES: int = 60 # Кулдаун уведомлений (минуты)
TRAFFIC_SNAPSHOT_TTL_HOURS: int = 24 # TTL для snapshot трафика в Redis (часы)
# Настройки суточных подписок
DAILY_SUBSCRIPTIONS_ENABLED: bool = True # Включить автоматическое списание для суточных тарифов
DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES: int = 30 # Интервал проверки в минутах
AUTOPAY_WARNING_DAYS: str = "3,1"
@@ -468,6 +477,8 @@ class Settings(BaseSettings):
FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None
# Использовать API для создания заказов (нужно для NSPK СБП)
FREEKASSA_USE_API: bool = False
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
SERVER_PUBLIC_IP: Optional[str] = None
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
@@ -517,6 +528,88 @@ class Settings(BaseSettings):
LOG_ERROR_FILE: str = "error.log"
LOG_PAYMENTS_FILE: str = "payments.log"
# === Ban Notification Messages ===
# Сообщение о блокировке за превышение лимита устройств
# Переменные: {ip_count}, {limit}, {ban_minutes}, {node_info}
BAN_MSG_PUNISHMENT: str = (
"🚫 <b>АККАУНТ ЗАБЛОКИРОВАН</b>\n"
"━━━━━━━━━━━━━━━━━━━━━\n\n"
"❌ <b>Причина:</b> Превышен лимит устройств\n"
"{node_info}\n"
"📊 <b>Детали нарушения:</b>\n"
"├ 📱 Устройств подключено: <b>{ip_count}</b>\n"
"├ 📋 Разрешено по тарифу: <b>{limit}</b>\n"
"└ ⏱ Время блокировки: <b>{ban_minutes} мин</b>\n\n"
"━━━━━━━━━━━━━━━━━━━━━\n"
"💡 <b>Что делать:</b>\n"
"1. Отключите лишние устройства от VPN\n"
"2. Дождитесь окончания блокировки\n"
"3. Подключитесь заново\n\n"
"🔄 Доступ восстановится автоматически"
)
# Сообщение о разблокировке
BAN_MSG_ENABLED: str = (
"✅ <b>АККАУНТ РАЗБЛОКИРОВАН</b>\n"
"━━━━━━━━━━━━━━━━━━━━━\n\n"
"🎉 Ваш аккаунт успешно разблокирован!\n\n"
"Теперь вы можете снова пользоваться VPN.\n\n"
"━━━━━━━━━━━━━━━━━━━━━\n"
"⚠️ <b>Рекомендации:</b>\n"
"• Следите за количеством устройств\n"
"• Отключайте VPN когда не используете\n"
"• Не превышайте лимит по тарифу"
)
# Сообщение о блокировке за WiFi
# Переменные: {ban_minutes}, {network_info}, {node_info}
BAN_MSG_WIFI: str = (
"🚫 <b>АККАУНТ ЗАБЛОКИРОВАН</b>\n"
"━━━━━━━━━━━━━━━━━━━━━\n\n"
"❌ <b>Причина:</b> Использование WiFi сети\n"
"{node_info}\n"
"📊 <b>Детали:</b>\n"
"├ 📶 Тип подключения: <b>WiFi</b>\n"
"{network_info}"
"└ ⏱ Время блокировки: <b>{ban_minutes} мин</b>\n\n"
"━━━━━━━━━━━━━━━━━━━━━\n"
"💡 <b>Что делать:</b>\n"
"1. Отключитесь от WiFi\n"
"2. Используйте мобильный интернет\n"
"3. Дождитесь окончания блокировки\n\n"
"🔄 Доступ восстановится автоматически"
)
# Сообщение о блокировке за мобильную сеть
# Переменные: {ban_minutes}, {network_info}, {node_info}
BAN_MSG_MOBILE: str = (
"🚫 <b>АККАУНТ ЗАБЛОКИРОВАН</b>\n"
"━━━━━━━━━━━━━━━━━━━━━\n\n"
"❌ <b>Причина:</b> Использование мобильной сети\n"
"{node_info}\n"
"📊 <b>Детали:</b>\n"
"├ 📱 Тип подключения: <b>Мобильная сеть</b>\n"
"{network_info}"
"└ ⏱ Время блокировки: <b>{ban_minutes} мин</b>\n\n"
"━━━━━━━━━━━━━━━━━━━━━\n"
"💡 <b>Что делать:</b>\n"
"1. Подключитесь к WiFi\n"
"2. Дождитесь окончания блокировки\n"
"3. Используйте VPN только через WiFi\n\n"
"🔄 Доступ восстановится автоматически"
)
# Сообщение-предупреждение
# Переменные: {warning_message}
BAN_MSG_WARNING: str = (
"⚠️ <b>ПРЕДУПРЕЖДЕНИЕ</b>\n"
"━━━━━━━━━━━━━━━━━━━━━\n\n"
"{warning_message}\n\n"
"━━━━━━━━━━━━━━━━━━━━━\n"
"❗ При повторном нарушении аккаунт будет заблокирован"
)
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
@@ -1696,42 +1789,110 @@ class Settings(BaseSettings):
return self.MAINTENANCE_MONITORING_ENABLED
def get_available_subscription_periods(self) -> List[int]:
"""
Возвращает доступные периоды подписки.
Использует AVAILABLE_SUBSCRIPTION_PERIODS для фильтрации,
а PERIOD_PRICES (из БД или .env) для проверки что цена > 0.
"""
from app.config import PERIOD_PRICES, get_db_period_prices
# Получаем разрешённые периоды из настройки
try:
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
if not periods_str.strip():
return [30, 90, 180]
periods = []
if not periods_str or not periods_str.strip():
allowed_periods = {14, 30, 60, 90, 180, 360}
else:
allowed_periods = set()
for period_str in periods_str.split(','):
period_str = period_str.strip()
if period_str:
period = int(period_str)
if hasattr(self, f'PRICE_{period}_DAYS'):
periods.append(period)
allowed_periods.add(int(period_str))
except (ValueError, AttributeError):
allowed_periods = {14, 30, 60, 90, 180, 360}
# Получаем цены из БД или .env
db_prices = get_db_period_prices()
prices = db_prices if db_prices else PERIOD_PRICES
# Возвращаем только разрешённые периоды с ценой > 0
periods = sorted([
days for days in allowed_periods
if days in prices and prices.get(days, 0) > 0
])
return periods if periods else [30, 90, 180]
except (ValueError, AttributeError):
return [30, 90, 180]
def get_available_renewal_periods(self) -> List[int]:
"""
Возвращает доступные периоды продления.
Использует AVAILABLE_RENEWAL_PERIODS для фильтрации,
а PERIOD_PRICES (из БД или .env) для проверки что цена > 0.
"""
from app.config import PERIOD_PRICES, get_db_period_prices
# Получаем разрешённые периоды из настройки
try:
periods_str = self.AVAILABLE_RENEWAL_PERIODS
if not periods_str.strip():
return [30, 90, 180]
if not periods_str or not periods_str.strip():
allowed_periods = {30, 60, 90, 180, 360}
else:
allowed_periods = set()
for period_str in periods_str.split(','):
period_str = period_str.strip()
if period_str:
allowed_periods.add(int(period_str))
except (ValueError, AttributeError):
allowed_periods = {30, 60, 90, 180, 360}
# Получаем цены из БД или .env
db_prices = get_db_period_prices()
prices = db_prices if db_prices else PERIOD_PRICES
# Возвращаем только разрешённые периоды с ценой > 0
periods = sorted([
days for days in allowed_periods
if days in prices and prices.get(days, 0) > 0
])
return periods if periods else [30, 90, 180]
def get_configured_subscription_periods(self) -> List[int]:
"""
Возвращает настроенные периоды подписки из AVAILABLE_SUBSCRIPTION_PERIODS.
БЕЗ фильтрации по ценам - используется для админки.
"""
try:
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
if not periods_str or not periods_str.strip():
return [14, 30, 60, 90, 180, 360]
periods = []
for period_str in periods_str.split(','):
period_str = period_str.strip()
if period_str:
period = int(period_str)
if hasattr(self, f'PRICE_{period}_DAYS'):
periods.append(period)
return periods if periods else [30, 90, 180]
periods.append(int(period_str))
return sorted(periods) if periods else [14, 30, 60, 90, 180, 360]
except (ValueError, AttributeError):
return [30, 90, 180]
return [14, 30, 60, 90, 180, 360]
def get_configured_renewal_periods(self) -> List[int]:
"""
Возвращает настроенные периоды продления из AVAILABLE_RENEWAL_PERIODS.
БЕЗ фильтрации по ценам - используется для админки.
"""
try:
periods_str = self.AVAILABLE_RENEWAL_PERIODS
if not periods_str or not periods_str.strip():
return [30, 60, 90, 180, 360]
periods = []
for period_str in periods_str.split(','):
period_str = period_str.strip()
if period_str:
periods.append(int(period_str))
return sorted(periods) if periods else [30, 60, 90, 180, 360]
except (ValueError, AttributeError):
return [30, 60, 90, 180, 360]
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: Optional[int] = None) -> str:
# Базовое описание
@@ -2104,6 +2265,20 @@ class Settings(BaseSettings):
def is_support_contact_enabled(self) -> bool:
return self.get_support_system_mode() in {"contact", "both"}
# MiniApp tickets settings
def is_miniapp_tickets_enabled(self) -> bool:
"""Check if tickets are enabled in miniapp."""
return bool(self.MINIAPP_TICKETS_ENABLED)
def get_miniapp_support_type(self) -> str:
"""Get miniapp support type: tickets, profile, or url."""
support_type = (self.MINIAPP_SUPPORT_TYPE or "tickets").strip().lower()
return support_type if support_type in {"tickets", "profile", "url"} else "tickets"
def get_miniapp_support_url(self) -> str:
"""Get custom support URL for miniapp (when type is 'url')."""
return (self.MINIAPP_SUPPORT_URL or "").strip()
def get_bot_run_mode(self) -> str:
mode = (self.BOT_RUN_MODE or "polling").strip().lower()
if mode not in {"polling", "webhook", "both"}:
@@ -2226,11 +2401,37 @@ _PERIOD_PRICE_FIELDS: Dict[int, str] = {
360: "PRICE_360_DAYS",
}
# Хранилище периодов/цен из БД (приоритет над .env)
_DB_PERIOD_PRICES: Optional[Dict[int, int]] = None
def set_period_prices_from_db(period_prices: Dict[int, int]) -> None:
"""
Устанавливает периоды/цены из БД.
Вызывается после синхронизации тарифов при запуске бота.
"""
global _DB_PERIOD_PRICES
_DB_PERIOD_PRICES = period_prices.copy() if period_prices else None
refresh_period_prices()
def get_db_period_prices() -> Optional[Dict[int, int]]:
"""Возвращает периоды/цены из БД если они загружены."""
return _DB_PERIOD_PRICES
def refresh_period_prices() -> None:
"""Rebuild cached period price mapping using the latest settings."""
"""
Rebuild cached period price mapping.
Приоритет: БД > .env
"""
PERIOD_PRICES.clear()
if _DB_PERIOD_PRICES:
# Используем цены из БД
PERIOD_PRICES.update(_DB_PERIOD_PRICES)
else:
# Fallback на .env
PERIOD_PRICES.update(
{
days: getattr(settings, field_name, 0)
+169 -2
View File
@@ -151,6 +151,7 @@ async def get_all_server_squads(
async def get_available_server_squads(
db: AsyncSession,
promo_group_id: Optional[int] = None,
exclude_trial_only: bool = False,
) -> List[ServerSquad]:
query = (
@@ -160,6 +161,9 @@ async def get_available_server_squads(
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
)
if exclude_trial_only:
query = query.where(ServerSquad.is_trial_eligible.is_(False))
if promo_group_id is not None:
query = query.join(ServerSquad.allowed_promo_groups).where(
PromoGroup.id == promo_group_id
@@ -526,22 +530,110 @@ async def get_random_trial_squad_uuid(
def _generate_display_name(original_name: str) -> str:
"""Генерирует отображаемое название сервера на основе оригинального имени."""
country_names = {
# Европа
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
'US': '🇺🇸 США',
'FR': '🇫🇷 Франция',
'GB': '🇬🇧 Великобритания',
'UK': '🇬🇧 Великобритания',
'IT': '🇮🇹 Италия',
'ES': '🇪🇸 Испания',
'PT': '🇵🇹 Португалия',
'PL': '🇵🇱 Польша',
'CZ': '🇨🇿 Чехия',
'AT': '🇦🇹 Австрия',
'CH': '🇨🇭 Швейцария',
'SE': '🇸🇪 Швеция',
'NO': '🇳🇴 Норвегия',
'FI': '🇫🇮 Финляндия',
'DK': '🇩🇰 Дания',
'BE': '🇧🇪 Бельгия',
'IE': '🇮🇪 Ирландия',
'RO': '🇷🇴 Румыния',
'BG': '🇧🇬 Болгария',
'HU': '🇭🇺 Венгрия',
'GR': '🇬🇷 Греция',
'LV': '🇱🇻 Латвия',
'LT': '🇱🇹 Литва',
'EE': '🇪🇪 Эстония',
'SK': '🇸🇰 Словакия',
'SI': '🇸🇮 Словения',
'HR': '🇭🇷 Хорватия',
'RS': '🇷🇸 Сербия',
'UA': '🇺🇦 Украина',
'MD': '🇲🇩 Молдова',
'BY': '🇧🇾 Беларусь',
'LU': '🇱🇺 Люксембург',
# СНГ и Азия
'RU': '🇷🇺 Россия',
'KZ': '🇰🇿 Казахстан',
'UZ': '🇺🇿 Узбекистан',
'GE': '🇬🇪 Грузия',
'AM': '🇦🇲 Армения',
'AZ': '🇦🇿 Азербайджан',
# Америка
'US': '🇺🇸 США',
'CA': '🇨🇦 Канада',
'MX': '🇲🇽 Мексика',
'BR': '🇧🇷 Бразилия',
'AR': '🇦🇷 Аргентина',
'CL': '🇨🇱 Чили',
'CO': '🇨🇴 Колумбия',
# Азия
'JP': '🇯🇵 Япония',
'KR': '🇰🇷 Южная Корея',
'CN': '🇨🇳 Китай',
'HK': '🇭🇰 Гонконг',
'TW': '🇹🇼 Тайвань',
'SG': '🇸🇬 Сингапур',
'TH': '🇹🇭 Таиланд',
'VN': '🇻🇳 Вьетнам',
'MY': '🇲🇾 Малайзия',
'ID': '🇮🇩 Индонезия',
'PH': '🇵🇭 Филиппины',
'IN': '🇮🇳 Индия',
'PK': '🇵🇰 Пакистан',
# Ближний Восток
'IL': '🇮🇱 Израиль',
'TR': '🇹🇷 Турция',
'AE': '🇦🇪 ОАЭ',
'SA': '🇸🇦 Саудовская Аравия',
'QA': '🇶🇦 Катар',
'BH': '🇧🇭 Бахрейн',
'KW': '🇰🇼 Кувейт',
# Океания
'AU': '🇦🇺 Австралия',
'NZ': '🇳🇿 Новая Зеландия',
# Африка
'ZA': '🇿🇦 ЮАР',
'EG': '🇪🇬 Египет',
'NG': '🇳🇬 Нигерия',
'KE': '🇰🇪 Кения',
}
name_upper = original_name.upper()
# Сначала ищем код как отдельный элемент (через - или _)
for code, display_name in country_names.items():
if f'-{code}' in name_upper or f'_{code}' in name_upper:
return display_name
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
return display_name
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
return display_name
if name_upper == code:
return display_name
# Потом ищем просто вхождение кода
for code, display_name in country_names.items():
if code in name_upper:
return display_name
@@ -550,10 +642,42 @@ def _generate_display_name(original_name: str) -> str:
def _extract_country_code(original_name: str) -> Optional[str]:
"""Извлекает код страны из оригинального названия."""
# Полный список кодов стран
codes = [
# Европа
'NL', 'DE', 'FR', 'GB', 'UK', 'IT', 'ES', 'PT', 'PL', 'CZ', 'AT', 'CH',
'SE', 'NO', 'FI', 'DK', 'BE', 'IE', 'RO', 'BG', 'HU', 'GR', 'LV', 'LT',
'EE', 'SK', 'SI', 'HR', 'RS', 'UA', 'MD', 'BY', 'LU',
# СНГ
'RU', 'KZ', 'UZ', 'GE', 'AM', 'AZ',
# Америка
'US', 'CA', 'MX', 'BR', 'AR', 'CL', 'CO',
# Азия
'JP', 'KR', 'CN', 'HK', 'TW', 'SG', 'TH', 'VN', 'MY', 'ID', 'PH', 'IN', 'PK',
# Ближний Восток
'IL', 'TR', 'AE', 'SA', 'QA', 'BH', 'KW',
# Океания
'AU', 'NZ',
# Африка
'ZA', 'EG', 'NG', 'KE',
]
codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU']
name_upper = original_name.upper()
# Сначала ищем код как отдельный элемент
for code in codes:
if f'-{code}' in name_upper or f'_{code}' in name_upper:
return code
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
return code
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
return code
if name_upper == code:
return code
# Потом просто ищем вхождение
for code in codes:
if code in name_upper:
return code
@@ -681,6 +805,49 @@ async def get_server_ids_by_uuids(
return [row[0] for row in result.fetchall()]
async def ensure_servers_synced(db: AsyncSession) -> None:
"""
Проверяет и синхронизирует серверы при запуске.
Если серверов нет в БД, загружает их из RemnaWave.
Вызывается при старте бота.
"""
try:
# Проверяем есть ли серверы в БД
result = await db.execute(select(func.count(ServerSquad.id)))
server_count = result.scalar() or 0
if server_count > 0:
logger.info(f"В базе уже есть {server_count} серверов, пропускаем синхронизацию")
return
logger.info("🔄 Серверов в БД нет, начинаем синхронизацию с RemnaWave...")
# Импортируем сервис здесь чтобы избежать циклических импортов
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
if not subscription_service.is_configured:
logger.warning("⚠️ RemnaWave не настроен, серверы не синхронизированы")
return
# Получаем скводы из RemnaWave
squads = await subscription_service.get_remnawave_squads()
if squads is None:
logger.error("❌ Не удалось получить список серверов из RemnaWave")
return
if not squads:
logger.warning("⚠️ RemnaWave вернул пустой список серверов")
return
# Синхронизируем
created, updated, removed = await sync_with_remnawave(db, squads)
logger.info(f"✅ Серверы синхронизированы: +{created} ~{updated} -{removed}")
except Exception as e:
logger.error(f"❌ Ошибка синхронизации серверов: {e}")
async def sync_server_user_counts(db: AsyncSession) -> int:
try:
+353 -21
View File
@@ -24,7 +24,10 @@ logger = logging.getLogger(__name__)
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
.limit(1)
@@ -243,6 +246,7 @@ async def replace_subscription(
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
subscription.device_limit = device_limit
subscription.connected_squads = list(new_squads)
subscription.subscription_url = None
@@ -316,12 +320,33 @@ async def extend_subscription(
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}")
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
is_tariff_change = (
tariff_id is not None
and subscription.tariff_id is not None
and tariff_id != subscription.tariff_id
)
if is_tariff_change:
logger.info(f"🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id}{tariff_id}")
# Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла
# и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа.
# Если подписка ещё активна - просто добавляем дни к существующей дате окончания.
bonus_days = 0
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
"📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s",
abs(days),
subscription.end_date,
)
elif is_tariff_change:
# При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала
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:
@@ -331,23 +356,23 @@ async def extend_subscription(
bonus_days,
subscription.id,
)
# Применяем продление с учетом бонусных дней
total_days = days + bonus_days
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
"📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s",
abs(days),
subscription.end_date,
)
elif subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=total_days)
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=total_days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
subscription.start_date = current_time
logger.info(f"📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней")
elif subscription.end_date > current_time:
# Подписка активна - просто добавляем дни к текущей дате окончания
# БЕЗ бонусных дней (они уже учтены в end_date)
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
else:
# Подписка истекла - начинаем с текущей даты + бонус от триала
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# Триал истёк, но бонус всё равно не добавляем (триал уже истёк)
pass
total_days = days + bonus_days
subscription.end_date = current_time + timedelta(days=total_days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней")
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
@@ -389,12 +414,23 @@ async def extend_subscription(
old_traffic = subscription.traffic_limit_gb
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
# Сбрасываем все докупки трафика при смене тарифа
from app.database.models import TrafficPurchase
from sqlalchemy import delete as sql_delete
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ")
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)")
elif settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
# В режиме тарифов сохраняем докупленный трафик при продлении
if subscription.tariff_id is None:
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
else:
# При продлении в режиме тарифов - сохраняем purchased_traffic_gb и traffic_reset_at
logger.info("🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)")
if device_limit is not None:
old_devices = subscription.device_limit
@@ -406,6 +442,24 @@ async def extend_subscription(
subscription.connected_squads = connected_squads
logger.info(f"🌍 Обновлены сквады: {old_squads}{connected_squads}")
# Обработка daily полей при смене тарифа
if is_tariff_change and tariff_id is not None:
# Получаем информацию о новом тарифе для проверки is_daily
from app.database.crud.tariff import get_tariff_by_id
new_tariff = await get_tariff_by_id(db, tariff_id)
old_was_daily = getattr(subscription, 'is_daily_paused', False) or getattr(subscription, 'last_daily_charge_at', None) is not None
if new_tariff and getattr(new_tariff, 'is_daily', False):
# Переход на суточный тариф - сбрасываем флаги
subscription.is_daily_paused = False
subscription.last_daily_charge_at = None # Будет установлено при первом списании
logger.info(f"🔄 Переход на суточный тариф: сброшены daily флаги")
elif old_was_daily:
# Переход с суточного на обычный тариф - очищаем daily поля
subscription.is_daily_paused = False
subscription.last_daily_charge_at = None
logger.info(f"🔄 Переход с суточного тарифа: очищены daily флаги")
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
# Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим)
# Если у подписки есть tariff_id - трафик определяется тарифом, не сбрасываем
@@ -415,6 +469,7 @@ async def extend_subscription(
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
subscription.traffic_limit_gb = fixed_limit
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info(f"🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ")
subscription.updated_at = current_time
@@ -438,10 +493,46 @@ async def add_subscription_traffic(
subscription.add_traffic(gb)
subscription.updated_at = datetime.utcnow()
# Создаём новую запись докупки с индивидуальной датой истечения (30 дней)
from app.database.models import TrafficPurchase
from sqlalchemy import select as sql_select
from datetime import timedelta
new_expires_at = datetime.utcnow() + timedelta(days=30)
new_purchase = TrafficPurchase(
subscription_id=subscription.id,
traffic_gb=gb,
expires_at=new_expires_at
)
db.add(new_purchase)
# Обновляем общий счетчик докупленного трафика
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
subscription.purchased_traffic_gb = current_purchased + gb
# Устанавливаем traffic_reset_at на ближайшую дату истечения из всех активных докупок
now = datetime.utcnow()
active_purchases_query = (
sql_select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
.where(TrafficPurchase.expires_at > now)
)
active_purchases_result = await db.execute(active_purchases_query)
active_purchases = active_purchases_result.scalars().all()
if active_purchases:
# Добавляем только что созданную покупку к списку
all_active = list(active_purchases) + [new_purchase]
earliest_expiry = min(p.expires_at for p in all_active)
subscription.traffic_reset_at = earliest_expiry
else:
# Первая докупка
subscription.traffic_reset_at = new_expires_at
await db.commit()
await db.refresh(subscription)
logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика")
logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика (истекает {new_expires_at.strftime('%d.%m.%Y')})")
return subscription
@@ -1454,6 +1545,15 @@ async def check_and_update_subscription_status(
format_local_datetime(current_time),
)
# Для суточных тарифов с паузой не меняем статус на expired
# (время "заморожено" пока пользователь на паузе)
is_daily_paused = getattr(subscription, 'is_daily_paused', False)
if is_daily_paused:
logger.info(
f"⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения"
)
return subscription
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
@@ -1740,3 +1840,235 @@ async def activate_pending_subscription(
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
return pending_subscription
async def activate_pending_trial_subscription(
db: AsyncSession,
subscription_id: int,
user_id: int,
) -> Optional[Subscription]:
"""Активирует pending триальную подписку по её ID после оплаты."""
logger.info(f"Активация pending триальной подписки: subscription_id={subscription_id}, user_id={user_id}")
# Находим pending подписку по ID
result = await db.execute(
select(Subscription)
.where(
and_(
Subscription.id == subscription_id,
Subscription.user_id == user_id,
Subscription.status == SubscriptionStatus.PENDING.value,
Subscription.is_trial == True
)
)
)
pending_subscription = result.scalar_one_or_none()
if not pending_subscription:
logger.warning(f"Не найдена pending триальная подписка {subscription_id} для пользователя {user_id}")
return None
logger.info(f"Найдена pending триальная подписка {pending_subscription.id}, статус: {pending_subscription.status}")
# Обновляем статус подписки на ACTIVE
current_time = datetime.utcnow()
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем даты
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
pending_subscription.start_date = current_time
# Пересчитываем end_date на основе duration_days если есть
duration_days = pending_subscription.duration_days if hasattr(pending_subscription, 'duration_days') else None
if duration_days:
pending_subscription.end_date = current_time + timedelta(days=duration_days)
elif pending_subscription.end_date and pending_subscription.end_date < current_time:
# Если end_date в прошлом, пересчитываем
from app.config import settings
pending_subscription.end_date = current_time + timedelta(days=settings.TRIAL_DURATION_DAYS)
await db.commit()
await db.refresh(pending_subscription)
logger.info(f"Триальная подписка {pending_subscription.id} активирована для пользователя {user_id}")
return pending_subscription
# ==================== СУТОЧНЫЕ ПОДПИСКИ ====================
async def get_daily_subscriptions_for_charge(db: AsyncSession) -> List[Subscription]:
"""
Получает все суточные подписки, которые нужно обработать для списания.
Критерии:
- Тариф подписки суточный (is_daily=True)
- Подписка активна
- Подписка не приостановлена пользователем
- Прошло более 24 часов с последнего списания (или списания ещё не было)
"""
from app.database.models import Tariff
now = datetime.utcnow()
one_day_ago = now - timedelta(hours=24)
query = (
select(Subscription)
.join(Tariff, Subscription.tariff_id == Tariff.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
and_(
Tariff.is_daily.is_(True),
Tariff.is_active.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_daily_paused.is_(False),
# Списания ещё не было ИЛИ прошло более 24 часов
(
(Subscription.last_daily_charge_at.is_(None)) |
(Subscription.last_daily_charge_at < one_day_ago)
),
)
)
)
result = await db.execute(query)
subscriptions = result.scalars().all()
logger.info(
f"🔍 Найдено {len(subscriptions)} суточных подписок для списания"
)
return list(subscriptions)
async def pause_daily_subscription(
db: AsyncSession,
subscription: Subscription,
) -> Subscription:
"""Приостанавливает суточную подписку (списание не будет происходить)."""
if not subscription.is_daily_tariff:
logger.warning(
f"Попытка приостановить не-суточную подписку {subscription.id}"
)
return subscription
subscription.is_daily_paused = True
await db.commit()
await db.refresh(subscription)
logger.info(
f"⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}"
)
return subscription
async def resume_daily_subscription(
db: AsyncSession,
subscription: Subscription,
) -> Subscription:
"""Возобновляет суточную подписку (списание продолжится)."""
if not subscription.is_daily_tariff:
logger.warning(
f"Попытка возобновить не-суточную подписку {subscription.id}"
)
return subscription
subscription.is_daily_paused = False
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
if subscription.status == SubscriptionStatus.DISABLED.value:
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=1)
logger.info(
f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE"
)
await db.commit()
await db.refresh(subscription)
logger.info(
f"▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}"
)
return subscription
async def update_daily_charge_time(
db: AsyncSession,
subscription: Subscription,
charge_time: datetime = None,
) -> Subscription:
"""Обновляет время последнего суточного списания и продлевает подписку на 1 день."""
now = charge_time or datetime.utcnow()
subscription.last_daily_charge_at = now
# Продлеваем подписку на 1 день от текущего момента
new_end_date = now + timedelta(days=1)
if subscription.end_date is None or subscription.end_date < new_end_date:
subscription.end_date = new_end_date
logger.info(f"📅 Продлена подписка {subscription.id} до {new_end_date}")
await db.commit()
await db.refresh(subscription)
return subscription
async def suspend_daily_subscription_insufficient_balance(
db: AsyncSession,
subscription: Subscription,
) -> Subscription:
"""
Приостанавливает подписку из-за недостатка баланса.
Отличается от pause_daily_subscription тем, что меняет статус на DISABLED.
"""
subscription.status = SubscriptionStatus.DISABLED.value
await db.commit()
await db.refresh(subscription)
logger.info(
f"⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})"
)
return subscription
async def get_subscription_with_tariff(
db: AsyncSession,
user_id: int,
) -> Optional[Subscription]:
"""Получает подписку пользователя с загруженным тарифом."""
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
.limit(1)
)
subscription = result.scalar_one_or_none()
if subscription:
subscription = await check_and_update_subscription_status(db, subscription)
return subscription
async def toggle_daily_subscription_pause(
db: AsyncSession,
subscription: Subscription,
) -> Subscription:
"""Переключает состояние паузы суточной подписки."""
if subscription.is_daily_paused:
return await resume_daily_subscription(db, subscription)
else:
return await pause_daily_subscription(db, subscription)
+21 -9
View File
@@ -52,22 +52,32 @@ async def get_conversion_by_user_id(
async def get_conversion_statistics(db: AsyncSession) -> dict:
from app.database.models import Subscription
# Получаем количество записей о конверсиях в таблице
total_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id))
)
total_conversions = total_conversions_result.scalar()
total_conversions = total_conversions_result.scalar() or 0
# Подсчитываем пользователей с платными подписками
users_with_paid_result = await db.execute(
select(func.count(User.id))
.where(User.has_had_paid_subscription == True)
)
users_with_paid = users_with_paid_result.scalar()
users_with_paid = users_with_paid_result.scalar() or 0
if total_conversions > 0:
conversion_rate = round((total_conversions / max(total_conversions, users_with_paid)) * 100, 1)
elif users_with_paid > 0:
conversion_rate = 100.0
# Подсчитываем всех пользователей с подписками (использовавших триал)
# Считаем что все новые пользователи начинают с триала
total_users_with_subscriptions_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id)))
)
total_users_with_subscriptions = total_users_with_subscriptions_result.scalar() or 0
# Расчёт конверсии: (оплатившие) / (всего с подписками) * 100
# Это показывает какой % пользователей, получивших подписку, в итоге оплатили
if total_users_with_subscriptions > 0:
conversion_rate = round((users_with_paid / total_users_with_subscriptions) * 100, 1)
else:
conversion_rate = 0.0
@@ -86,16 +96,18 @@ async def get_conversion_statistics(db: AsyncSession) -> dict:
select(func.count(SubscriptionConversion.id))
.where(SubscriptionConversion.converted_at >= month_ago)
)
month_conversions = month_conversions_result.scalar()
month_conversions = month_conversions_result.scalar() or 0
logger.info(f"📊 Статистика конверсий:")
logger.info(f" Всего записей о конверсиях: {total_conversions}")
logger.info(f" Пользователей с платными подписками: {users_with_paid}")
logger.info(f" Всего пользователей с подписками: {total_users_with_subscriptions}")
logger.info(f" Оплативших подписку: {users_with_paid}")
logger.info(f" Рассчитанная конверсия: {conversion_rate}%")
return {
"total_conversions": total_conversions,
"conversion_rate": conversion_rate,
"users_with_trial": total_users_with_subscriptions,
"converted_users": users_with_paid,
"avg_trial_duration_days": round(avg_trial_duration, 1),
"avg_first_payment_rubles": round((avg_first_payment or 0) / 100, 2),
"month_conversions": month_conversions
+224
View File
@@ -162,11 +162,32 @@ async def create_tariff(
is_active: bool = True,
traffic_limit_gb: int = 100,
device_limit: int = 1,
device_price_kopeks: Optional[int] = None,
max_device_limit: Optional[int] = None,
allowed_squads: Optional[List[str]] = None,
server_traffic_limits: Optional[Dict[str, dict]] = None,
period_prices: Optional[Dict[int, int]] = None,
tier_level: int = 1,
is_trial_available: bool = False,
allow_traffic_topup: bool = True,
promo_group_ids: Optional[List[int]] = None,
traffic_topup_enabled: bool = False,
traffic_topup_packages: Optional[Dict[str, int]] = None,
max_topup_traffic_gb: int = 0,
is_daily: bool = False,
daily_price_kopeks: int = 0,
# Произвольное количество дней
custom_days_enabled: bool = False,
price_per_day_kopeks: int = 0,
min_days: int = 1,
max_days: int = 365,
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False,
traffic_price_per_gb_kopeks: int = 0,
min_traffic_gb: int = 1,
max_traffic_gb: int = 1000,
# Режим сброса трафика
traffic_reset_mode: Optional[str] = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
) -> Tariff:
"""Создает новый тариф."""
normalized_prices = _normalize_period_prices(period_prices)
@@ -178,10 +199,31 @@ async def create_tariff(
is_active=is_active,
traffic_limit_gb=max(0, traffic_limit_gb),
device_limit=max(1, device_limit),
device_price_kopeks=device_price_kopeks,
max_device_limit=max_device_limit,
allowed_squads=allowed_squads or [],
server_traffic_limits=server_traffic_limits or {},
period_prices=normalized_prices,
tier_level=max(1, tier_level),
is_trial_available=is_trial_available,
allow_traffic_topup=allow_traffic_topup,
traffic_topup_enabled=traffic_topup_enabled,
traffic_topup_packages=traffic_topup_packages or {},
max_topup_traffic_gb=max(0, max_topup_traffic_gb),
is_daily=is_daily,
daily_price_kopeks=max(0, daily_price_kopeks),
# Произвольное количество дней
custom_days_enabled=custom_days_enabled,
price_per_day_kopeks=max(0, price_per_day_kopeks),
min_days=max(1, min_days),
max_days=max(1, max_days),
# Произвольный трафик при покупке
custom_traffic_enabled=custom_traffic_enabled,
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
min_traffic_gb=max(1, min_traffic_gb),
max_traffic_gb=max(1, max_traffic_gb),
# Режим сброса трафика
traffic_reset_mode=traffic_reset_mode,
)
db.add(tariff)
@@ -222,11 +264,31 @@ async def update_tariff(
traffic_limit_gb: Optional[int] = None,
device_limit: Optional[int] = None,
device_price_kopeks: Optional[int] = ..., # ... = не передан, None = сбросить
max_device_limit: Optional[int] = ..., # ... = не передан, None = сбросить (без лимита)
allowed_squads: Optional[List[str]] = None,
server_traffic_limits: Optional[Dict[str, dict]] = None,
period_prices: Optional[Dict[int, int]] = None,
tier_level: Optional[int] = None,
is_trial_available: Optional[bool] = None,
allow_traffic_topup: Optional[bool] = None,
promo_group_ids: Optional[List[int]] = None,
traffic_topup_enabled: Optional[bool] = None,
traffic_topup_packages: Optional[Dict[str, int]] = None,
max_topup_traffic_gb: Optional[int] = None,
is_daily: Optional[bool] = None,
daily_price_kopeks: Optional[int] = None,
# Произвольное количество дней
custom_days_enabled: Optional[bool] = None,
price_per_day_kopeks: Optional[int] = None,
min_days: Optional[int] = None,
max_days: Optional[int] = None,
# Произвольный трафик при покупке
custom_traffic_enabled: Optional[bool] = None,
traffic_price_per_gb_kopeks: Optional[int] = None,
min_traffic_gb: Optional[int] = None,
max_traffic_gb: Optional[int] = None,
# Режим сброса трафика
traffic_reset_mode: Optional[str] = ..., # ... = не передан, None = сбросить к глобальной настройке
) -> Tariff:
"""Обновляет существующий тариф."""
if name is not None:
@@ -244,14 +306,52 @@ async def update_tariff(
if device_price_kopeks is not ...:
# Если передан device_price_kopeks (включая None) - обновляем
tariff.device_price_kopeks = device_price_kopeks
if max_device_limit is not ...:
# Если передан max_device_limit (включая None) - обновляем
tariff.max_device_limit = max_device_limit
if allowed_squads is not None:
tariff.allowed_squads = allowed_squads
if server_traffic_limits is not None:
tariff.server_traffic_limits = server_traffic_limits
if allow_traffic_topup is not None:
tariff.allow_traffic_topup = allow_traffic_topup
if period_prices is not None:
tariff.period_prices = _normalize_period_prices(period_prices)
if tier_level is not None:
tariff.tier_level = max(1, tier_level)
if is_trial_available is not None:
tariff.is_trial_available = is_trial_available
if traffic_topup_enabled is not None:
tariff.traffic_topup_enabled = traffic_topup_enabled
if traffic_topup_packages is not None:
tariff.traffic_topup_packages = traffic_topup_packages
if max_topup_traffic_gb is not None:
tariff.max_topup_traffic_gb = max(0, max_topup_traffic_gb)
if is_daily is not None:
tariff.is_daily = is_daily
if daily_price_kopeks is not None:
tariff.daily_price_kopeks = max(0, daily_price_kopeks)
# Произвольное количество дней
if custom_days_enabled is not None:
tariff.custom_days_enabled = custom_days_enabled
if price_per_day_kopeks is not None:
tariff.price_per_day_kopeks = max(0, price_per_day_kopeks)
if min_days is not None:
tariff.min_days = max(1, min_days)
if max_days is not None:
tariff.max_days = max(1, max_days)
# Произвольный трафик при покупке
if custom_traffic_enabled is not None:
tariff.custom_traffic_enabled = custom_traffic_enabled
if traffic_price_per_gb_kopeks is not None:
tariff.traffic_price_per_gb_kopeks = max(0, traffic_price_per_gb_kopeks)
if min_traffic_gb is not None:
tariff.min_traffic_gb = max(1, min_traffic_gb)
if max_traffic_gb is not None:
tariff.max_traffic_gb = max(1, max_traffic_gb)
# Режим сброса трафика
if traffic_reset_mode is not ...:
tariff.traffic_reset_mode = traffic_reset_mode
# Обновляем промогруппы если указаны
if promo_group_ids is not None:
@@ -399,3 +499,127 @@ async def reorder_tariffs(
await db.commit()
logger.info("Изменен порядок тарифов: %s", tariff_order)
async def sync_default_tariff_from_config(db: AsyncSession) -> Optional[Tariff]:
"""
Синхронизирует дефолтный тариф из конфига (.env) в БД.
Создаёт тариф "Стандартный" если в БД нет тарифов.
Обновляет цены существующего тарифа если он есть.
Returns:
Tariff или None если не требуется синхронизация
"""
from app.config import settings, PERIOD_PRICES
# Проверяем есть ли тарифы в БД
result = await db.execute(select(func.count(Tariff.id)))
tariff_count = result.scalar() or 0
# Собираем цены из конфига
period_prices = {}
for period, price in PERIOD_PRICES.items():
if price > 0:
period_prices[str(period)] = price
if not period_prices:
logger.warning("Нет цен в конфиге для создания дефолтного тарифа")
return None
# Ищем тариф с именем "Стандартный" или первый тариф
result = await db.execute(
select(Tariff).where(Tariff.name == "Стандартный").limit(1)
)
existing_tariff = result.scalar_one_or_none()
if existing_tariff:
# Обновляем цены существующего тарифа
existing_tariff.period_prices = period_prices
existing_tariff.traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
existing_tariff.device_limit = settings.DEFAULT_DEVICE_LIMIT
await db.commit()
await db.refresh(existing_tariff)
logger.info("Обновлён дефолтный тариф 'Стандартный' из конфига")
return existing_tariff
if tariff_count == 0:
# Создаём новый дефолтный тариф
new_tariff = Tariff(
name="Стандартный",
description="Базовый тарифный план",
is_active=True,
is_trial_available=True,
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=settings.DEFAULT_DEVICE_LIMIT,
tier_level=1,
display_order=0,
period_prices=period_prices,
allowed_squads=[], # Все серверы по умолчанию
server_traffic_limits={},
)
db.add(new_tariff)
await db.commit()
await db.refresh(new_tariff)
logger.info("Создан дефолтный тариф 'Стандартный' из конфига: %s", period_prices)
return new_tariff
return None
async def load_period_prices_from_db(db: AsyncSession) -> None:
"""
Загружает периоды/цены из тарифа в PERIOD_PRICES.
Работает ТОЛЬКО в режиме tariffs. В режиме classic используются цены из .env.
"""
from app.config import set_period_prices_from_db, settings
# В режиме classic НЕ загружаем цены из тарифов - используем .env
if settings.is_classic_mode():
logger.info("Режим classic: цены периодов берутся из .env, тарифы игнорируются")
return
try:
# Ищем тариф "Стандартный" или первый активный тариф
result = await db.execute(
select(Tariff)
.where(Tariff.is_active.is_(True))
.order_by(Tariff.display_order, Tariff.id)
.limit(1)
)
tariff = result.scalar_one_or_none()
if tariff and tariff.period_prices:
# Преобразуем строковые ключи в int
period_prices = {
int(days): int(price)
for days, price in tariff.period_prices.items()
if int(price) > 0
}
if period_prices:
set_period_prices_from_db(period_prices)
logger.info(
"Загружены периоды из тарифа '%s': %s",
tariff.name,
{f"{d}д": f"{p//100}" for d, p in period_prices.items()}
)
else:
logger.warning("Тариф '%s' не имеет активных периодов", tariff.name)
else:
logger.info("Активные тарифы не найдены, используются цены из .env")
except Exception as e:
logger.error("Ошибка загрузки периодов из БД: %s", e)
async def ensure_tariffs_synced(db: AsyncSession) -> None:
"""
Проверяет и синхронизирует тарифы при запуске.
Вызывается при старте бота.
"""
try:
await sync_default_tariff_from_config(db)
# Загружаем периоды из БД в PERIOD_PRICES
await load_period_prices_from_db(db)
except Exception as e:
logger.error("Ошибка синхронизации тарифов: %s", e)
+438
View File
@@ -0,0 +1,438 @@
"""
CRUD операции для колеса удачи (Fortune Wheel).
"""
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy import select, and_, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
WheelConfig,
WheelPrize,
WheelSpin,
WheelPrizeType,
WheelSpinPaymentType,
User,
)
logger = logging.getLogger(__name__)
# ==================== WHEEL CONFIG ====================
async def get_wheel_config(db: AsyncSession) -> Optional[WheelConfig]:
"""Получить текущую конфигурацию колеса (всегда id=1)."""
result = await db.execute(
select(WheelConfig)
.options(selectinload(WheelConfig.prizes))
.where(WheelConfig.id == 1)
)
return result.scalar_one_or_none()
async def get_or_create_wheel_config(db: AsyncSession) -> WheelConfig:
"""Получить или создать конфигурацию колеса."""
config = await get_wheel_config(db)
if config:
return config
# Создаем дефолтную конфигурацию
config = WheelConfig(
id=1,
is_enabled=False,
name="Колесо удачи",
spin_cost_stars=10,
spin_cost_days=1,
spin_cost_stars_enabled=True,
spin_cost_days_enabled=True,
rtp_percent=80,
daily_spin_limit=5,
min_subscription_days_for_day_payment=3,
promo_prefix="WHEEL",
promo_validity_days=7,
)
db.add(config)
await db.commit()
await db.refresh(config)
logger.info("🎡 Создана дефолтная конфигурация колеса удачи")
return config
async def update_wheel_config(
db: AsyncSession,
**kwargs
) -> WheelConfig:
"""Обновить конфигурацию колеса."""
config = await get_or_create_wheel_config(db)
for key, value in kwargs.items():
if hasattr(config, key) and value is not None:
setattr(config, key, value)
config.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(config)
logger.info(f"🎡 Обновлена конфигурация колеса: {kwargs}")
return config
# ==================== WHEEL PRIZES ====================
async def get_wheel_prizes(
db: AsyncSession,
config_id: int = 1,
active_only: bool = True
) -> List[WheelPrize]:
"""Получить список призов колеса."""
query = select(WheelPrize).where(WheelPrize.config_id == config_id)
if active_only:
query = query.where(WheelPrize.is_active == True)
query = query.order_by(WheelPrize.sort_order)
result = await db.execute(query)
return list(result.scalars().all())
async def get_wheel_prize_by_id(db: AsyncSession, prize_id: int) -> Optional[WheelPrize]:
"""Получить приз по ID."""
result = await db.execute(
select(WheelPrize).where(WheelPrize.id == prize_id)
)
return result.scalar_one_or_none()
async def create_wheel_prize(
db: AsyncSession,
config_id: int,
prize_type: str,
prize_value: int,
display_name: str,
prize_value_kopeks: int,
emoji: str = "🎁",
color: str = "#3B82F6",
sort_order: int = 0,
manual_probability: Optional[float] = None,
is_active: bool = True,
promo_balance_bonus_kopeks: int = 0,
promo_subscription_days: int = 0,
promo_traffic_gb: int = 0,
) -> WheelPrize:
"""Создать новый приз на колесе."""
prize = WheelPrize(
config_id=config_id,
prize_type=prize_type,
prize_value=prize_value,
display_name=display_name,
prize_value_kopeks=prize_value_kopeks,
emoji=emoji,
color=color,
sort_order=sort_order,
manual_probability=manual_probability,
is_active=is_active,
promo_balance_bonus_kopeks=promo_balance_bonus_kopeks,
promo_subscription_days=promo_subscription_days,
promo_traffic_gb=promo_traffic_gb,
)
db.add(prize)
await db.commit()
await db.refresh(prize)
logger.info(f"🎁 Создан приз колеса: {display_name} ({prize_type})")
return prize
async def update_wheel_prize(
db: AsyncSession,
prize_id: int,
**kwargs
) -> Optional[WheelPrize]:
"""Обновить приз колеса."""
prize = await get_wheel_prize_by_id(db, prize_id)
if not prize:
return None
for key, value in kwargs.items():
if hasattr(prize, key) and value is not None:
setattr(prize, key, value)
prize.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(prize)
logger.info(f"🎁 Обновлен приз колеса ID={prize_id}: {kwargs}")
return prize
async def delete_wheel_prize(db: AsyncSession, prize_id: int) -> bool:
"""Удалить приз колеса."""
prize = await get_wheel_prize_by_id(db, prize_id)
if not prize:
return False
await db.delete(prize)
await db.commit()
logger.info(f"🗑️ Удален приз колеса ID={prize_id}")
return True
async def reorder_wheel_prizes(
db: AsyncSession,
prize_ids: List[int]
) -> bool:
"""Переупорядочить призы колеса."""
for index, prize_id in enumerate(prize_ids):
prize = await get_wheel_prize_by_id(db, prize_id)
if prize:
prize.sort_order = index
await db.commit()
logger.info(f"🔄 Переупорядочены призы колеса: {prize_ids}")
return True
# ==================== WHEEL SPINS ====================
async def create_wheel_spin(
db: AsyncSession,
user_id: int,
prize_id: int,
payment_type: str,
payment_amount: int,
payment_value_kopeks: int,
prize_type: str,
prize_value: int,
prize_display_name: str,
prize_value_kopeks: int,
generated_promocode_id: Optional[int] = None,
is_applied: bool = False,
) -> WheelSpin:
"""Создать запись о спине колеса."""
spin = WheelSpin(
user_id=user_id,
prize_id=prize_id,
payment_type=payment_type,
payment_amount=payment_amount,
payment_value_kopeks=payment_value_kopeks,
prize_type=prize_type,
prize_value=prize_value,
prize_display_name=prize_display_name,
prize_value_kopeks=prize_value_kopeks,
generated_promocode_id=generated_promocode_id,
is_applied=is_applied,
applied_at=datetime.utcnow() if is_applied else None,
)
db.add(spin)
await db.commit()
await db.refresh(spin)
logger.info(f"🎰 Создан спин колеса: user_id={user_id}, prize='{prize_display_name}'")
return spin
async def mark_spin_applied(db: AsyncSession, spin_id: int) -> Optional[WheelSpin]:
"""Отметить спин как примененный."""
result = await db.execute(
select(WheelSpin).where(WheelSpin.id == spin_id)
)
spin = result.scalar_one_or_none()
if spin:
spin.is_applied = True
spin.applied_at = datetime.utcnow()
await db.commit()
await db.refresh(spin)
return spin
async def get_user_spins_today(db: AsyncSession, user_id: int) -> int:
"""Получить количество спинов пользователя за сегодня."""
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(WheelSpin.id))
.where(
and_(
WheelSpin.user_id == user_id,
WheelSpin.created_at >= today_start,
)
)
)
return result.scalar() or 0
async def get_user_spin_history(
db: AsyncSession,
user_id: int,
limit: int = 20,
offset: int = 0
) -> tuple[List[WheelSpin], int]:
"""Получить историю спинов пользователя."""
# Общее количество
count_result = await db.execute(
select(func.count(WheelSpin.id))
.where(WheelSpin.user_id == user_id)
)
total = count_result.scalar() or 0
# Спины с пагинацией (eager load prize relationship)
result = await db.execute(
select(WheelSpin)
.options(selectinload(WheelSpin.prize))
.where(WheelSpin.user_id == user_id)
.order_by(desc(WheelSpin.created_at))
.limit(limit)
.offset(offset)
)
spins = list(result.scalars().all())
return spins, total
async def get_all_spins(
db: AsyncSession,
user_id: Optional[int] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
limit: int = 50,
offset: int = 0
) -> tuple[List[WheelSpin], int]:
"""Получить все спины с фильтрами (для админки)."""
conditions = []
if user_id:
conditions.append(WheelSpin.user_id == user_id)
if date_from:
conditions.append(WheelSpin.created_at >= date_from)
if date_to:
conditions.append(WheelSpin.created_at <= date_to)
# Общее количество
count_query = select(func.count(WheelSpin.id))
if conditions:
count_query = count_query.where(and_(*conditions))
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
# Спины с пагинацией
query = select(WheelSpin).options(selectinload(WheelSpin.user))
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(desc(WheelSpin.created_at)).limit(limit).offset(offset)
result = await db.execute(query)
spins = list(result.scalars().all())
return spins, total
# ==================== STATISTICS ====================
async def get_wheel_statistics(
db: AsyncSession,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> Dict[str, Any]:
"""Получить статистику колеса удачи."""
conditions = []
if date_from:
conditions.append(WheelSpin.created_at >= date_from)
if date_to:
conditions.append(WheelSpin.created_at <= date_to)
base_query = select(WheelSpin)
if conditions:
base_query = base_query.where(and_(*conditions))
# Общие метрики
result = await db.execute(
select(
func.count(WheelSpin.id).label("total_spins"),
func.coalesce(func.sum(WheelSpin.payment_value_kopeks), 0).label("total_revenue"),
func.coalesce(func.sum(WheelSpin.prize_value_kopeks), 0).label("total_payout"),
).where(and_(*conditions) if conditions else True)
)
row = result.one()
total_spins = row.total_spins or 0
total_revenue = row.total_revenue or 0
total_payout = row.total_payout or 0
# Фактический RTP
actual_rtp = (total_payout / total_revenue * 100) if total_revenue > 0 else 0
# Распределение по типу оплаты
payment_dist = await db.execute(
select(
WheelSpin.payment_type,
func.count(WheelSpin.id).label("count"),
func.sum(WheelSpin.payment_value_kopeks).label("total"),
)
.where(and_(*conditions) if conditions else True)
.group_by(WheelSpin.payment_type)
)
spins_by_payment_type = {
row.payment_type: {"count": row.count, "total_kopeks": row.total or 0}
for row in payment_dist
}
# Распределение призов
prizes_dist = await db.execute(
select(
WheelSpin.prize_type,
WheelSpin.prize_display_name,
func.count(WheelSpin.id).label("count"),
func.sum(WheelSpin.prize_value_kopeks).label("total"),
)
.where(and_(*conditions) if conditions else True)
.group_by(WheelSpin.prize_type, WheelSpin.prize_display_name)
)
prizes_distribution = [
{
"prize_type": row.prize_type,
"display_name": row.prize_display_name,
"count": row.count,
"total_kopeks": row.total or 0,
}
for row in prizes_dist
]
# Топ выигрышей
top_wins_result = await db.execute(
select(WheelSpin)
.options(selectinload(WheelSpin.user))
.where(and_(*conditions) if conditions else True)
.where(WheelSpin.prize_value_kopeks > 0)
.order_by(desc(WheelSpin.prize_value_kopeks))
.limit(10)
)
top_wins = [
{
"user_id": spin.user_id,
"username": spin.user.username if spin.user else None,
"prize_display_name": spin.prize_display_name,
"prize_value_kopeks": spin.prize_value_kopeks,
"created_at": spin.created_at.isoformat() if spin.created_at else None,
}
for spin in top_wins_result.scalars().all()
]
# Конфигурация для сравнения
config = await get_wheel_config(db)
configured_rtp = config.rtp_percent if config else 80
return {
"total_spins": total_spins,
"total_revenue_kopeks": total_revenue,
"total_payout_kopeks": total_payout,
"actual_rtp_percent": round(actual_rtp, 2),
"configured_rtp_percent": configured_rtp,
"spins_by_payment_type": spins_by_payment_type,
"prizes_distribution": prizes_distribution,
"top_wins": top_wins,
"period_from": date_from.isoformat() if date_from else None,
"period_to": date_to.isoformat() if date_to else None,
}
+293 -1
View File
@@ -108,6 +108,7 @@ class PaymentMethod(Enum):
CLOUDPAYMENTS = "cloudpayments"
FREEKASSA = "freekassa"
MANUAL = "manual"
BALANCE = "balance"
class MainMenuButtonActionType(Enum):
@@ -120,6 +121,21 @@ class MainMenuButtonVisibility(Enum):
ADMINS = "admins"
SUBSCRIBERS = "subscribers"
class WheelPrizeType(Enum):
"""Типы призов на колесе удачи."""
SUBSCRIPTION_DAYS = "subscription_days"
BALANCE_BONUS = "balance_bonus"
TRAFFIC_GB = "traffic_gb"
PROMOCODE = "promocode"
NOTHING = "nothing"
class WheelSpinPaymentType(Enum):
"""Способы оплаты спина колеса."""
TELEGRAM_STARS = "telegram_stars"
SUBSCRIPTION_DAYS = "subscription_days"
class YooKassaPayment(Base):
__tablename__ = "yookassa_payments"
@@ -749,10 +765,15 @@ class Tariff(Base):
traffic_limit_gb = Column(Integer, nullable=False, default=100) # 0 = безлимит
device_limit = Column(Integer, nullable=False, default=1)
device_price_kopeks = Column(Integer, nullable=True, default=None) # Цена за доп. устройство (None = нельзя докупить)
max_device_limit = Column(Integer, nullable=True, default=None) # Макс. устройств (None = без ограничений)
# Сквады (серверы) доступные в тарифе
allowed_squads = Column(JSON, default=list) # список UUID сквадов
# Лимиты трафика по серверам (JSON: {"uuid": {"traffic_limit_gb": 100}, ...})
# Если сервер не указан - используется общий traffic_limit_gb
server_traffic_limits = Column(JSON, default=dict)
# Цены на периоды в копейках (JSON: {"14": 30000, "30": 50000, "90": 120000, ...})
period_prices = Column(JSON, nullable=False, default=dict)
@@ -761,6 +782,33 @@ class Tariff(Base):
# Дополнительные настройки
is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе
allow_traffic_topup = Column(Boolean, default=True, nullable=False) # Разрешена ли докупка трафика для этого тарифа
# Докупка трафика
traffic_topup_enabled = Column(Boolean, default=False, nullable=False) # Разрешена ли докупка трафика
# Пакеты трафика: JSON {"5": 5000, "10": 9000, "20": 15000} (ГБ: цена в копейках)
traffic_topup_packages = Column(JSON, default=dict)
# Максимальный лимит трафика после докупки (0 = без ограничений)
max_topup_traffic_gb = Column(Integer, default=0, nullable=False)
# Суточный тариф - ежедневное списание
is_daily = Column(Boolean, default=False, nullable=False) # Является ли тариф суточным
daily_price_kopeks = Column(Integer, default=0, nullable=False) # Цена за день в копейках
# Произвольное количество дней
custom_days_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольное кол-во дней
price_per_day_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 день в копейках
min_days = Column(Integer, default=1, nullable=False) # Минимальное количество дней
max_days = Column(Integer, default=365, nullable=False) # Максимальное количество дней
# Произвольный трафик при покупке
custom_traffic_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольный трафик
traffic_price_per_gb_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 ГБ в копейках
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
@@ -797,6 +845,21 @@ class Tariff(Base):
return price_kopeks / 100
return None
def get_traffic_limit_for_server(self, squad_uuid: str) -> int:
"""Возвращает лимит трафика для конкретного сервера.
Если для сервера настроен отдельный лимит - возвращает его,
иначе возвращает общий traffic_limit_gb тарифа.
"""
limits = self.server_traffic_limits or {}
if squad_uuid in limits:
server_limit = limits[squad_uuid]
if isinstance(server_limit, dict) and 'traffic_limit_gb' in server_limit:
return server_limit['traffic_limit_gb']
elif isinstance(server_limit, int):
return server_limit
return self.traffic_limit_gb
def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool:
"""Проверяет, доступен ли тариф для указанной промогруппы."""
if not self.allowed_promo_groups:
@@ -805,6 +868,57 @@ class Tariff(Base):
return True # Если у пользователя нет группы - доступен
return any(pg.id == promo_group_id for pg in self.allowed_promo_groups)
def get_traffic_topup_packages(self) -> Dict[int, int]:
"""Возвращает пакеты трафика для докупки: {ГБ: цена в копейках}."""
packages = self.traffic_topup_packages or {}
return {int(gb): int(price) for gb, price in packages.items()}
def get_traffic_topup_price(self, gb: int) -> Optional[int]:
"""Возвращает цену в копейках для указанного пакета трафика."""
packages = self.get_traffic_topup_packages()
return packages.get(gb)
def get_available_traffic_packages(self) -> List[int]:
"""Возвращает список доступных пакетов трафика в ГБ."""
packages = self.get_traffic_topup_packages()
return sorted(packages.keys())
def can_topup_traffic(self) -> bool:
"""Проверяет, можно ли докупить трафик на этом тарифе."""
return (
self.traffic_topup_enabled
and bool(self.traffic_topup_packages)
and not self.is_unlimited_traffic
)
def get_daily_price_rubles(self) -> float:
"""Возвращает суточную цену в рублях."""
return self.daily_price_kopeks / 100 if self.daily_price_kopeks else 0
def get_price_for_custom_days(self, days: int) -> Optional[int]:
"""Возвращает цену для произвольного количества дней."""
if not self.custom_days_enabled or not self.price_per_day_kopeks:
return None
if days < self.min_days or days > self.max_days:
return None
return self.price_per_day_kopeks * days
def get_price_for_custom_traffic(self, gb: int) -> Optional[int]:
"""Возвращает цену для произвольного количества трафика."""
if not self.custom_traffic_enabled or not self.traffic_price_per_gb_kopeks:
return None
if gb < self.min_traffic_gb or gb > self.max_traffic_gb:
return None
return self.traffic_price_per_gb_kopeks * gb
def can_purchase_custom_days(self) -> bool:
"""Проверяет, можно ли купить произвольное количество дней."""
return self.custom_days_enabled and self.price_per_day_kopeks > 0
def can_purchase_custom_traffic(self) -> bool:
"""Проверяет, можно ли купить произвольный трафик."""
return self.custom_traffic_enabled and self.traffic_price_per_gb_kopeks > 0
def __repr__(self):
return f"<Tariff(id={self.id}, name='{self.name}', tier={self.tier_level}, active={self.is_active})>"
@@ -940,7 +1054,8 @@ class Subscription(Base):
traffic_limit_gb = Column(Integer, default=0)
traffic_used_gb = Column(Float, default=0.0)
purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса)
purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик
traffic_reset_at = Column(DateTime, nullable=True) # Дата сброса докупленного трафика (30 дней после первой докупки)
subscription_url = Column(String, nullable=True)
subscription_crypto_link = Column(String, nullable=True)
@@ -961,10 +1076,15 @@ class Subscription(Base):
# Тариф (для режима продаж "Тарифы")
tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True, index=True)
# Суточная подписка
is_daily_paused = Column(Boolean, default=False, nullable=False) # Приостановлена ли суточная подписка пользователем
last_daily_charge_at = Column(DateTime, nullable=True) # Время последнего суточного списания
user = relationship("User", back_populates="subscription")
tariff = relationship("Tariff", back_populates="subscriptions")
discount_offers = relationship("DiscountOffer", back_populates="subscription")
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
traffic_purchases = relationship("TrafficPurchase", back_populates="subscription", cascade="all, delete-orphan")
@property
def is_active(self) -> bool:
@@ -1097,6 +1217,51 @@ class Subscription(Base):
return
self.traffic_limit_gb += gb
@property
def is_daily_tariff(self) -> bool:
"""Проверяет, является ли тариф подписки суточным."""
if self.tariff:
return getattr(self.tariff, 'is_daily', False)
return False
@property
def daily_price_kopeks(self) -> int:
"""Возвращает суточную цену тарифа в копейках."""
if self.tariff:
return getattr(self.tariff, 'daily_price_kopeks', 0)
return 0
@property
def can_charge_daily(self) -> bool:
"""Проверяет, можно ли списать суточную оплату."""
if not self.is_daily_tariff:
return False
if self.is_daily_paused:
return False
if self.status != SubscriptionStatus.ACTIVE.value:
return False
return True
class TrafficPurchase(Base):
"""Докупка трафика с индивидуальной датой истечения."""
__tablename__ = "traffic_purchases"
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False, index=True)
traffic_gb = Column(Integer, nullable=False) # Количество ГБ в покупке
expires_at = Column(DateTime, nullable=False, index=True) # Дата истечения (покупка + 30 дней)
created_at = Column(DateTime, default=func.now())
subscription = relationship("Subscription", back_populates="traffic_purchases")
@property
def is_expired(self) -> bool:
"""Проверяет, истекла ли докупка."""
return datetime.utcnow() >= self.expires_at
class Transaction(Base):
__tablename__ = "transactions"
@@ -2250,3 +2415,130 @@ class CabinetRefreshToken(Base):
def __repr__(self) -> str:
status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired")
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
# ==================== FORTUNE WHEEL ====================
class WheelConfig(Base):
"""Глобальная конфигурация колеса удачи."""
__tablename__ = "wheel_configs"
id = Column(Integer, primary_key=True, index=True)
# Основные настройки
is_enabled = Column(Boolean, default=False, nullable=False)
name = Column(String(255), default="Колесо удачи", nullable=False)
# Стоимость спина
spin_cost_stars = Column(Integer, default=10, nullable=False) # Стоимость в Stars
spin_cost_days = Column(Integer, default=1, nullable=False) # Стоимость в днях подписки
spin_cost_stars_enabled = Column(Boolean, default=True, nullable=False)
spin_cost_days_enabled = Column(Boolean, default=True, nullable=False)
# RTP настройки (Return to Player) - процент возврата 0-100
rtp_percent = Column(Integer, default=80, nullable=False)
# Лимиты
daily_spin_limit = Column(Integer, default=5, nullable=False) # 0 = без лимита
min_subscription_days_for_day_payment = Column(Integer, default=3, nullable=False)
# Генерация промокодов
promo_prefix = Column(String(20), default="WHEEL", nullable=False)
promo_validity_days = Column(Integer, default=7, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
prizes = relationship("WheelPrize", back_populates="config", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<WheelConfig id={self.id} enabled={self.is_enabled} rtp={self.rtp_percent}%>"
class WheelPrize(Base):
"""Приз на колесе удачи."""
__tablename__ = "wheel_prizes"
id = Column(Integer, primary_key=True, index=True)
config_id = Column(Integer, ForeignKey("wheel_configs.id", ondelete="CASCADE"), nullable=False)
# Тип и значение приза
prize_type = Column(String(50), nullable=False) # WheelPrizeType
prize_value = Column(Integer, default=0, nullable=False) # Дни/копейки/GB в зависимости от типа
# Отображение
display_name = Column(String(100), nullable=False)
emoji = Column(String(10), default="🎁", nullable=False)
color = Column(String(20), default="#3B82F6", nullable=False) # HEX цвет сектора
# Стоимость приза для расчета RTP (в копейках)
prize_value_kopeks = Column(Integer, default=0, nullable=False)
# Порядок и вероятность
sort_order = Column(Integer, default=0, nullable=False)
manual_probability = Column(Float, nullable=True) # Если задано - игнорирует RTP расчет (0.0-1.0)
is_active = Column(Boolean, default=True, nullable=False)
# Настройки генерируемого промокода (только для prize_type=promocode)
promo_balance_bonus_kopeks = Column(Integer, default=0)
promo_subscription_days = Column(Integer, default=0)
promo_traffic_gb = Column(Integer, default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
config = relationship("WheelConfig", back_populates="prizes")
spins = relationship("WheelSpin", back_populates="prize")
def __repr__(self) -> str:
return f"<WheelPrize id={self.id} type={self.prize_type} name='{self.display_name}'>"
class WheelSpin(Base):
"""История спинов колеса удачи."""
__tablename__ = "wheel_spins"
__table_args__ = (
Index("ix_wheel_spins_user_created", "user_id", "created_at"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
prize_id = Column(Integer, ForeignKey("wheel_prizes.id", ondelete="SET NULL"), nullable=True)
# Способ оплаты
payment_type = Column(String(50), nullable=False) # WheelSpinPaymentType
payment_amount = Column(Integer, nullable=False) # Stars или дни
payment_value_kopeks = Column(Integer, nullable=False) # Эквивалент в копейках для статистики
# Результат
prize_type = Column(String(50), nullable=False) # Копируем из WheelPrize на момент спина
prize_value = Column(Integer, nullable=False)
prize_display_name = Column(String(100), nullable=False)
prize_value_kopeks = Column(Integer, nullable=False) # Стоимость приза в копейках
# Сгенерированный промокод (если приз - промокод)
generated_promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=True)
# Флаг успешного начисления
is_applied = Column(Boolean, default=False, nullable=False)
applied_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", backref="wheel_spins")
prize = relationship("WheelPrize", back_populates="spins")
generated_promocode = relationship("PromoCode")
@property
def prize_value_rubles(self) -> float:
"""Стоимость приза в рублях."""
return self.prize_value_kopeks / 100
@property
def payment_value_rubles(self) -> float:
"""Стоимость оплаты в рублях."""
return self.payment_value_kopeks / 100
def __repr__(self) -> str:
return f"<WheelSpin id={self.id} user_id={self.user_id} prize='{self.prize_display_name}'>"
+840
View File
@@ -5135,6 +5135,68 @@ async def create_withdrawal_requests_table() -> bool:
return False
# =============================================================================
# МИГРАЦИЯ ДЛЯ ИНДИВИДУАЛЬНЫХ ДОКУПОК ТРАФИКА
# =============================================================================
async def create_traffic_purchases_table() -> bool:
"""Создаёт таблицу для индивидуальных докупок трафика с отдельными датами истечения."""
try:
if await check_table_exists('traffic_purchases'):
logger.info("️ Таблица traffic_purchases уже существует")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE traffic_purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL,
traffic_gb INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
);
CREATE INDEX idx_traffic_purchases_subscription_id ON traffic_purchases(subscription_id);
CREATE INDEX idx_traffic_purchases_expires_at ON traffic_purchases(expires_at);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE traffic_purchases (
id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
traffic_gb INTEGER NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_traffic_purchases_subscription_id ON traffic_purchases(subscription_id);
CREATE INDEX idx_traffic_purchases_expires_at ON traffic_purchases(expires_at);
"""
else: # mysql
create_sql = """
CREATE TABLE traffic_purchases (
id INT AUTO_INCREMENT PRIMARY KEY,
subscription_id INT NOT NULL,
traffic_gb INT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
INDEX idx_traffic_purchases_subscription_id (subscription_id),
INDEX idx_traffic_purchases_expires_at (expires_at)
);
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица traffic_purchases создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы traffic_purchases: {error}")
return False
# =============================================================================
# МИГРАЦИИ ДЛЯ РЕЖИМА ТАРИФОВ
# =============================================================================
@@ -5259,6 +5321,38 @@ async def create_tariff_promo_groups_table() -> bool:
return False
async def ensure_tariff_max_device_limit_column() -> bool:
"""Добавляет колонку max_device_limit в таблицу tariffs."""
try:
column_exists = await check_column_exists('tariffs', 'max_device_limit')
if column_exists:
logger.info("️ Колонка max_device_limit в tariffs уже существует")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_device_limit INTEGER NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_device_limit INTEGER NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_device_limit INT NULL"
))
logger.info("✅ Колонка max_device_limit добавлена в tariffs")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки max_device_limit: {error}")
return False
async def add_subscription_tariff_id_column() -> bool:
"""Добавляет колонку tariff_id в таблицу subscriptions."""
try:
@@ -5332,6 +5426,679 @@ async def add_tariff_device_price_column() -> bool:
return False
async def add_tariff_server_traffic_limits_column() -> bool:
"""Добавляет колонку server_traffic_limits в таблицу tariffs."""
try:
if await check_column_exists('tariffs', 'server_traffic_limits'):
logger.info("️ Колонка server_traffic_limits уже существует в tariffs")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN server_traffic_limits TEXT DEFAULT '{}'"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSONB DEFAULT '{}'"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSON DEFAULT NULL"
))
logger.info("✅ Колонка server_traffic_limits добавлена в tariffs")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки server_traffic_limits: {error}")
return False
async def add_tariff_allow_traffic_topup_column() -> bool:
"""Добавляет колонку allow_traffic_topup в таблицу tariffs."""
try:
if await check_column_exists('tariffs', 'allow_traffic_topup'):
logger.info("️ Колонка allow_traffic_topup уже существует в tariffs")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN allow_traffic_topup INTEGER NOT NULL DEFAULT 1"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE"
))
logger.info("✅ Колонка allow_traffic_topup добавлена в tariffs")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки allow_traffic_topup: {error}")
return False
async def create_wheel_tables() -> bool:
"""Создаёт таблицы для колеса удачи: wheel_config, wheel_prizes, wheel_spins."""
try:
db_type = await get_database_type()
# Создание wheel_config
if not await check_table_exists('wheel_config'):
async with engine.begin() as conn:
if db_type == 'sqlite':
create_config_sql = """
CREATE TABLE wheel_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
is_enabled BOOLEAN NOT NULL DEFAULT 0,
name VARCHAR(255) NOT NULL DEFAULT 'Колесо удачи',
spin_cost_stars INTEGER NOT NULL DEFAULT 50,
spin_cost_days INTEGER NOT NULL DEFAULT 3,
spin_cost_stars_enabled BOOLEAN NOT NULL DEFAULT 1,
spin_cost_days_enabled BOOLEAN NOT NULL DEFAULT 1,
rtp_percent REAL NOT NULL DEFAULT 85.0,
daily_spin_limit INTEGER NOT NULL DEFAULT 5,
min_subscription_days_for_day_payment INTEGER NOT NULL DEFAULT 7,
promo_prefix VARCHAR(50) NOT NULL DEFAULT 'WHEEL',
promo_validity_days INTEGER NOT NULL DEFAULT 30,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""
elif db_type == 'postgresql':
create_config_sql = """
CREATE TABLE wheel_config (
id SERIAL PRIMARY KEY,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
name VARCHAR(255) NOT NULL DEFAULT 'Колесо удачи',
spin_cost_stars INTEGER NOT NULL DEFAULT 50,
spin_cost_days INTEGER NOT NULL DEFAULT 3,
spin_cost_stars_enabled BOOLEAN NOT NULL DEFAULT TRUE,
spin_cost_days_enabled BOOLEAN NOT NULL DEFAULT TRUE,
rtp_percent REAL NOT NULL DEFAULT 85.0,
daily_spin_limit INTEGER NOT NULL DEFAULT 5,
min_subscription_days_for_day_payment INTEGER NOT NULL DEFAULT 7,
promo_prefix VARCHAR(50) NOT NULL DEFAULT 'WHEEL',
promo_validity_days INTEGER NOT NULL DEFAULT 30,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
else: # mysql
create_config_sql = """
CREATE TABLE wheel_config (
id INT AUTO_INCREMENT PRIMARY KEY,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
name VARCHAR(255) NOT NULL DEFAULT 'Колесо удачи',
spin_cost_stars INT NOT NULL DEFAULT 50,
spin_cost_days INT NOT NULL DEFAULT 3,
spin_cost_stars_enabled BOOLEAN NOT NULL DEFAULT TRUE,
spin_cost_days_enabled BOOLEAN NOT NULL DEFAULT TRUE,
rtp_percent FLOAT NOT NULL DEFAULT 85.0,
daily_spin_limit INT NOT NULL DEFAULT 5,
min_subscription_days_for_day_payment INT NOT NULL DEFAULT 7,
promo_prefix VARCHAR(50) NOT NULL DEFAULT 'WHEEL',
promo_validity_days INT NOT NULL DEFAULT 30,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
"""
await conn.execute(text(create_config_sql))
logger.info("✅ Таблица wheel_config создана")
else:
logger.debug("️ Таблица wheel_config уже существует")
# Создание wheel_prizes
if not await check_table_exists('wheel_prizes'):
async with engine.begin() as conn:
if db_type == 'sqlite':
create_prizes_sql = """
CREATE TABLE wheel_prizes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
config_id INTEGER NOT NULL,
prize_type VARCHAR(50) NOT NULL,
prize_value INTEGER NOT NULL DEFAULT 0,
display_name VARCHAR(255) NOT NULL,
emoji VARCHAR(10) NOT NULL DEFAULT '🎁',
color VARCHAR(20) NOT NULL DEFAULT '#3B82F6',
prize_value_kopeks INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
manual_probability REAL,
is_active BOOLEAN NOT NULL DEFAULT 1,
promo_balance_bonus_kopeks INTEGER NOT NULL DEFAULT 0,
promo_subscription_days INTEGER NOT NULL DEFAULT 0,
promo_traffic_gb INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE
)
"""
elif db_type == 'postgresql':
create_prizes_sql = """
CREATE TABLE wheel_prizes (
id SERIAL PRIMARY KEY,
config_id INTEGER NOT NULL REFERENCES wheel_config(id) ON DELETE CASCADE,
prize_type VARCHAR(50) NOT NULL,
prize_value INTEGER NOT NULL DEFAULT 0,
display_name VARCHAR(255) NOT NULL,
emoji VARCHAR(10) NOT NULL DEFAULT '🎁',
color VARCHAR(20) NOT NULL DEFAULT '#3B82F6',
prize_value_kopeks INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
manual_probability REAL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
promo_balance_bonus_kopeks INTEGER NOT NULL DEFAULT 0,
promo_subscription_days INTEGER NOT NULL DEFAULT 0,
promo_traffic_gb INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
else: # mysql
create_prizes_sql = """
CREATE TABLE wheel_prizes (
id INT AUTO_INCREMENT PRIMARY KEY,
config_id INT NOT NULL,
prize_type VARCHAR(50) NOT NULL,
prize_value INT NOT NULL DEFAULT 0,
display_name VARCHAR(255) NOT NULL,
emoji VARCHAR(10) NOT NULL DEFAULT '🎁',
color VARCHAR(20) NOT NULL DEFAULT '#3B82F6',
prize_value_kopeks INT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
manual_probability FLOAT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
promo_balance_bonus_kopeks INT NOT NULL DEFAULT 0,
promo_subscription_days INT NOT NULL DEFAULT 0,
promo_traffic_gb INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE
)
"""
await conn.execute(text(create_prizes_sql))
# Индексы
try:
await conn.execute(text(
"CREATE INDEX idx_wheel_prizes_config_id ON wheel_prizes(config_id)"
))
except Exception:
pass
logger.info("✅ Таблица wheel_prizes создана")
else:
logger.debug("️ Таблица wheel_prizes уже существует")
# Создание wheel_spins
if not await check_table_exists('wheel_spins'):
async with engine.begin() as conn:
if db_type == 'sqlite':
create_spins_sql = """
CREATE TABLE wheel_spins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
config_id INTEGER NOT NULL,
prize_id INTEGER,
payment_type VARCHAR(50) NOT NULL,
payment_amount INTEGER NOT NULL,
payment_value_kopeks INTEGER NOT NULL DEFAULT 0,
prize_type VARCHAR(50) NOT NULL,
prize_value INTEGER NOT NULL DEFAULT 0,
prize_value_kopeks INTEGER NOT NULL DEFAULT 0,
promocode_id INTEGER,
is_applied BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE,
FOREIGN KEY (prize_id) REFERENCES wheel_prizes(id) ON DELETE SET NULL,
FOREIGN KEY (promocode_id) REFERENCES promocodes(id) ON DELETE SET NULL
)
"""
elif db_type == 'postgresql':
create_spins_sql = """
CREATE TABLE wheel_spins (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
config_id INTEGER NOT NULL REFERENCES wheel_config(id) ON DELETE CASCADE,
prize_id INTEGER REFERENCES wheel_prizes(id) ON DELETE SET NULL,
payment_type VARCHAR(50) NOT NULL,
payment_amount INTEGER NOT NULL,
payment_value_kopeks INTEGER NOT NULL DEFAULT 0,
prize_type VARCHAR(50) NOT NULL,
prize_value INTEGER NOT NULL DEFAULT 0,
prize_value_kopeks INTEGER NOT NULL DEFAULT 0,
promocode_id INTEGER REFERENCES promocodes(id) ON DELETE SET NULL,
is_applied BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
else: # mysql
create_spins_sql = """
CREATE TABLE wheel_spins (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
config_id INT NOT NULL,
prize_id INT,
payment_type VARCHAR(50) NOT NULL,
payment_amount INT NOT NULL,
payment_value_kopeks INT NOT NULL DEFAULT 0,
prize_type VARCHAR(50) NOT NULL,
prize_value INT NOT NULL DEFAULT 0,
prize_value_kopeks INT NOT NULL DEFAULT 0,
promocode_id INT,
is_applied BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE,
FOREIGN KEY (prize_id) REFERENCES wheel_prizes(id) ON DELETE SET NULL,
FOREIGN KEY (promocode_id) REFERENCES promocodes(id) ON DELETE SET NULL
)
"""
await conn.execute(text(create_spins_sql))
# Индексы
try:
await conn.execute(text(
"CREATE INDEX idx_wheel_spins_user_id ON wheel_spins(user_id)"
))
await conn.execute(text(
"CREATE INDEX idx_wheel_spins_created_at ON wheel_spins(created_at)"
))
except Exception:
pass
logger.info("✅ Таблица wheel_spins создана")
else:
logger.debug("️ Таблица wheel_spins уже существует")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблиц для колеса удачи: {error}")
return False
async def add_tariff_traffic_topup_columns() -> bool:
"""Добавляет колонки для докупки трафика в тарифах."""
try:
columns_added = 0
# Колонка traffic_topup_enabled
if not await check_column_exists('tariffs', 'traffic_topup_enabled'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled BOOLEAN DEFAULT FALSE NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled TINYINT(1) DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка traffic_topup_enabled добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка traffic_topup_enabled уже существует в tariffs")
# Колонка traffic_topup_packages (JSON)
if not await check_column_exists('tariffs', 'traffic_topup_packages'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_packages TEXT DEFAULT '{}'"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSONB DEFAULT '{}'"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSON DEFAULT NULL"
))
logger.info("✅ Колонка traffic_topup_packages добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка traffic_topup_packages уже существует в tariffs")
# Колонка max_topup_traffic_gb (максимальный лимит трафика после докупок)
if not await check_column_exists('tariffs', 'max_topup_traffic_gb'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INT DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка max_topup_traffic_gb добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка max_topup_traffic_gb уже существует в tariffs")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонок для докупки трафика: {error}")
return False
async def add_tariff_daily_columns() -> bool:
"""Добавляет колонки для суточных тарифов."""
try:
columns_added = 0
# Колонка is_daily
if not await check_column_exists('tariffs', 'is_daily'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN is_daily INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN is_daily BOOLEAN DEFAULT FALSE NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN is_daily TINYINT(1) DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка is_daily добавлена в tariffs")
columns_added += 1
else:
logger.info("ℹ️ Колонка is_daily уже существует в tariffs")
# Колонка daily_price_kopeks
if not await check_column_exists('tariffs', 'daily_price_kopeks'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INT DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка daily_price_kopeks добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка daily_price_kopeks уже существует в tariffs")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонок суточного тарифа: {error}")
return False
async def add_tariff_custom_days_traffic_columns() -> bool:
"""Добавляет колонки для произвольных дней и трафика в тарифы."""
try:
columns_added = 0
db_type = await get_database_type()
# === ПРОИЗВОЛЬНОЕ КОЛИЧЕСТВО ДНЕЙ ===
# custom_days_enabled
if not await check_column_exists('tariffs', 'custom_days_enabled'):
async with engine.begin() as conn:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_days_enabled INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_days_enabled BOOLEAN DEFAULT FALSE NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_days_enabled TINYINT(1) DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка custom_days_enabled добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка custom_days_enabled уже существует в tariffs")
# price_per_day_kopeks
if not await check_column_exists('tariffs', 'price_per_day_kopeks'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN price_per_day_kopeks INTEGER DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка price_per_day_kopeks добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка price_per_day_kopeks уже существует в tariffs")
# min_days
if not await check_column_exists('tariffs', 'min_days'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN min_days INTEGER DEFAULT 1 NOT NULL"
))
logger.info("✅ Колонка min_days добавлена в tariffs")
columns_added += 1
else:
logger.info("ℹ️ Колонка min_days уже существует в tariffs")
# max_days
if not await check_column_exists('tariffs', 'max_days'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_days INTEGER DEFAULT 365 NOT NULL"
))
logger.info("✅ Колонка max_days добавлена в tariffs")
columns_added += 1
else:
logger.info("ℹ️ Колонка max_days уже существует в tariffs")
# === ПРОИЗВОЛЬНЫЙ ТРАФИК ПРИ ПОКУПКЕ ===
# custom_traffic_enabled
if not await check_column_exists('tariffs', 'custom_traffic_enabled'):
async with engine.begin() as conn:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled BOOLEAN DEFAULT FALSE NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled TINYINT(1) DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка custom_traffic_enabled добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка custom_traffic_enabled уже существует в tariffs")
# traffic_price_per_gb_kopeks
if not await check_column_exists('tariffs', 'traffic_price_per_gb_kopeks'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_price_per_gb_kopeks INTEGER DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка traffic_price_per_gb_kopeks добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка traffic_price_per_gb_kopeks уже существует в tariffs")
# min_traffic_gb
if not await check_column_exists('tariffs', 'min_traffic_gb'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN min_traffic_gb INTEGER DEFAULT 1 NOT NULL"
))
logger.info("✅ Колонка min_traffic_gb добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка min_traffic_gb уже существует в tariffs")
# max_traffic_gb
if not await check_column_exists('tariffs', 'max_traffic_gb'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_traffic_gb INTEGER DEFAULT 1000 NOT NULL"
))
logger.info("✅ Колонка max_traffic_gb добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка max_traffic_gb уже существует в tariffs")
if columns_added > 0:
logger.info(f"✅ Добавлено {columns_added} колонок для произвольных дней/трафика")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонок произвольных дней/трафика: {error}")
return False
async def add_tariff_traffic_reset_mode_column() -> bool:
"""Добавляет колонку traffic_reset_mode в tariffs для настройки режима сброса трафика.
Значения: DAY, WEEK, MONTH, NO_RESET (NULL = использовать глобальную настройку)
"""
try:
if not await check_column_exists('tariffs', 'traffic_reset_mode'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_reset_mode VARCHAR(20) NULL"
))
logger.info("✅ Колонка traffic_reset_mode добавлена в tariffs")
return True
else:
logger.info("️ Колонка traffic_reset_mode уже существует в tariffs")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки traffic_reset_mode: {error}")
return False
async def add_subscription_daily_columns() -> bool:
"""Добавляет колонки для суточных подписок."""
try:
columns_added = 0
# Колонка is_daily_paused
if not await check_column_exists('subscriptions', 'is_daily_paused'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN is_daily_paused INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN is_daily_paused BOOLEAN DEFAULT FALSE NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN is_daily_paused TINYINT(1) DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка is_daily_paused добавлена в subscriptions")
columns_added += 1
else:
logger.info("️ Колонка is_daily_paused уже существует в subscriptions")
# Колонка last_daily_charge_at
if not await check_column_exists('subscriptions', 'last_daily_charge_at'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at TIMESTAMP NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL"
))
logger.info("✅ Колонка last_daily_charge_at добавлена в subscriptions")
columns_added += 1
else:
logger.info("️ Колонка last_daily_charge_at уже существует в subscriptions")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонок суточной подписки: {error}")
return False
async def add_subscription_traffic_reset_at_column() -> bool:
"""Добавляет колонку traffic_reset_at в subscriptions для сброса докупленного трафика через 30 дней."""
try:
if not await check_column_exists('subscriptions', 'traffic_reset_at'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN traffic_reset_at TIMESTAMP NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL"
))
logger.info("✅ Колонка traffic_reset_at добавлена в subscriptions")
return True
else:
logger.info("️ Колонка traffic_reset_at уже существует в subscriptions")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки traffic_reset_at: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -5809,6 +6576,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ ДОКУПОК ТРАФИКА ===")
traffic_purchases_ready = await create_traffic_purchases_table()
if traffic_purchases_ready:
logger.info("✅ Таблица traffic_purchases готова")
else:
logger.warning("⚠️ Проблемы с таблицей traffic_purchases")
logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕЖИМА ТАРИФОВ ===")
tariffs_table_ready = await create_tariffs_table()
if tariffs_table_ready:
@@ -5834,6 +6608,65 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs")
max_device_limit_ready = await ensure_tariff_max_device_limit_column()
if max_device_limit_ready:
logger.info("✅ Колонка max_device_limit в tariffs готова")
else:
logger.warning("⚠️ Проблемы с колонкой max_device_limit в tariffs")
server_traffic_limits_ready = await add_tariff_server_traffic_limits_column()
if server_traffic_limits_ready:
logger.info("✅ Колонка server_traffic_limits в tariffs готова")
else:
logger.warning("⚠️ Проблемы с колонкой server_traffic_limits в tariffs")
allow_traffic_topup_ready = await add_tariff_allow_traffic_topup_column()
if allow_traffic_topup_ready:
logger.info("✅ Колонка allow_traffic_topup в tariffs готова")
else:
logger.warning("⚠️ Проблемы с колонкой allow_traffic_topup в tariffs")
traffic_topup_columns_ready = await add_tariff_traffic_topup_columns()
if traffic_topup_columns_ready:
logger.info("✅ Колонки докупки трафика в tariffs готовы")
else:
logger.warning("⚠️ Проблемы с колонками докупки трафика в tariffs")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ТАРИФОВ ===")
daily_tariff_columns_ready = await add_tariff_daily_columns()
if daily_tariff_columns_ready:
logger.info("✅ Колонки суточных тарифов в tariffs готовы")
else:
logger.warning("⚠️ Проблемы с колонками суточных тарифов в tariffs")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ПРОИЗВОЛЬНЫХ ДНЕЙ/ТРАФИКА ===")
custom_days_traffic_ready = await add_tariff_custom_days_traffic_columns()
if custom_days_traffic_ready:
logger.info("✅ Колонки произвольных дней/трафика в tariffs готовы")
else:
logger.warning("⚠️ Проблемы с колонками произвольных дней/трафика в tariffs")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ РЕЖИМА СБРОСА ТРАФИКА В ТАРИФАХ ===")
traffic_reset_mode_ready = await add_tariff_traffic_reset_mode_column()
if traffic_reset_mode_ready:
logger.info("✅ Колонка traffic_reset_mode в tariffs готова")
else:
logger.warning("⚠️ Проблемы с колонкой traffic_reset_mode в tariffs")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===")
daily_subscription_columns_ready = await add_subscription_daily_columns()
if daily_subscription_columns_ready:
logger.info("✅ Колонки суточных подписок в subscriptions готовы")
else:
logger.warning("⚠️ Проблемы с колонками суточных подписок в subscriptions")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ СБРОСА ТРАФИКА ===")
traffic_reset_column_ready = await add_subscription_traffic_reset_at_column()
if traffic_reset_column_ready:
logger.info("✅ Колонка traffic_reset_at в subscriptions готова")
else:
logger.warning("⚠️ Проблемы с колонкой traffic_reset_at в subscriptions")
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
fk_updated = await fix_foreign_keys_for_user_deletion()
if fk_updated:
@@ -5869,6 +6702,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей withdrawal_requests")
logger.info("=== СОЗДАНИЕ ТАБЛИЦ КОЛЕСА УДАЧИ ===")
wheel_tables_ready = await create_wheel_tables()
if wheel_tables_ready:
logger.info("✅ Таблицы колеса удачи готовы")
else:
logger.warning("⚠️ Проблемы с таблицами колеса удачи")
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
+6 -2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio
import logging
import json
@@ -9,13 +11,15 @@ from ipaddress import (
ip_address,
ip_network,
)
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple, TYPE_CHECKING
from aiohttp import web
from app.config import settings
from app.services.payment_service import PaymentService
from app.database.database import get_db
if TYPE_CHECKING:
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
+340 -8
View File
@@ -18,6 +18,7 @@ from app.database.models import (
Subscription,
SubscriptionStatus,
BroadcastHistory,
Tariff,
)
from app.database.database import AsyncSessionLocal
from app.keyboards.admin import (
@@ -31,6 +32,7 @@ from app.keyboards.admin import (
from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
from app.database.crud.subscription import get_expiring_subscriptions
from app.database.crud.tariff import get_all_tariffs
from app.utils.decorators import admin_required, error_handler
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.pinned_message_service import (
@@ -42,6 +44,41 @@ from app.services.pinned_message_service import (
logger = logging.getLogger(__name__)
async def safe_edit_or_send_text(
callback: types.CallbackQuery,
text: str,
reply_markup=None,
parse_mode: str = "HTML"
):
"""
Безопасно редактирует сообщение или удаляет и отправляет новое.
Нужно для случаев, когда текущее сообщение - медиа (фото/видео),
которое нельзя отредактировать через edit_text.
"""
try:
await callback.message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
except TelegramBadRequest as e:
if "there is no text in the message to edit" in str(e):
# Сообщение - медиа без текста, удаляем и отправляем новое
try:
await callback.message.delete()
except Exception:
pass
await callback.bot.send_message(
chat_id=callback.message.chat.id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
raise
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
@@ -166,7 +203,8 @@ async def show_messages_menu(
Будьте осторожны с массовыми рассылками!
"""
await callback.message.edit_text(
await safe_edit_or_send_text(
callback,
text,
reply_markup=get_admin_messages_keyboard(db_user.language),
parse_mode="HTML"
@@ -486,6 +524,62 @@ async def show_broadcast_targets(
await callback.answer()
@admin_required
@error_handler
async def show_tariff_filter(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показывает список тарифов для фильтрации рассылки."""
tariffs = await get_all_tariffs(db, include_inactive=False)
if not tariffs:
await callback.message.edit_text(
"❌ <b>Нет доступных тарифов</b>\n\n"
"Создайте тарифы в разделе управления тарифами.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_msg_by_sub")]
]),
parse_mode="HTML"
)
await callback.answer()
return
# Получаем количество подписчиков на каждом тарифе
tariff_counts = {}
for tariff in tariffs:
count_query = (
select(func.count(Subscription.id))
.where(
Subscription.tariff_id == tariff.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
result = await db.execute(count_query)
tariff_counts[tariff.id] = result.scalar() or 0
buttons = []
for tariff in tariffs:
count = tariff_counts.get(tariff.id, 0)
buttons.append([
types.InlineKeyboardButton(
text=f"{tariff.name} ({count} чел.)",
callback_data=f"broadcast_tariff_{tariff.id}"
)
])
buttons.append([types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_msg_by_sub")])
await callback.message.edit_text(
"📦 <b>Рассылка по тарифу</b>\n\n"
"Выберите тариф для рассылки пользователям с активной подпиской на этот тариф:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_messages_history(
@@ -655,13 +749,24 @@ async def select_broadcast_target(
"trial_zero": "Триальная подписка, трафик 0 ГБ",
}
# Обработка фильтра по тарифу
target_name = target_names.get(target, target)
if target.startswith("tariff_"):
tariff_id = int(target.split("_")[1])
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if tariff:
target_name = f"Тариф «{tariff.name}»"
else:
target_name = f"Тариф #{tariff_id}"
user_count = await get_target_users_count(db, target)
await state.update_data(broadcast_target=target)
await callback.message.edit_text(
f"📨 <b>Создание рассылки</b>\n\n"
f"🎯 <b>Аудитория:</b> {target_names.get(target, target)}\n"
f"🎯 <b>Аудитория:</b> {target_name}\n"
f"👥 <b>Получателей:</b> {user_count}\n\n"
f"Введите текст сообщения для рассылки:\n\n"
f"<i>Поддерживается HTML разметка</i>",
@@ -865,7 +970,8 @@ async def handle_change_media(
db_user: User,
state: FSMContext
):
await callback.message.edit_text(
await safe_edit_or_send_text(
callback,
"🖼️ <b>Изменение медиафайла</b>\n\n"
"Выберите новый тип медиа:",
reply_markup=get_broadcast_media_keyboard(db_user.language),
@@ -1092,7 +1198,10 @@ async def confirm_button_selection(
media_file_id = data.get('media_file_id')
if media_file_id:
# Удаляем текущее сообщение и отправляем новое с фото
try:
await callback.message.delete()
except Exception:
pass
await callback.bot.send_photo(
chat_id=callback.message.chat.id,
photo=media_file_id,
@@ -1101,21 +1210,25 @@ async def confirm_button_selection(
parse_mode="HTML"
)
else:
# Если нет file_id, используем обычное редактирование
await callback.message.edit_text(
# Если нет file_id, используем safe редактирование
await safe_edit_or_send_text(
callback,
preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
else:
# Для текстовых сообщений или других типов медиа используем обычное редактирование
await callback.message.edit_text(
# Для текстовых сообщений или других типов медиа используем safe редактирование
await safe_edit_or_send_text(
callback,
preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def confirm_broadcast(
@@ -1135,7 +1248,8 @@ async def confirm_broadcast(
media_file_id = data.get('media_file_id')
media_caption = data.get('media_caption')
await callback.message.edit_text(
await safe_edit_or_send_text(
callback,
"📨 Начинаю рассылку...\n\n"
"⏳ Это может занять несколько минут.",
reply_markup=None,
@@ -1284,6 +1398,7 @@ async def confirm_broadcast(
<b>Администратор:</b> {db_user.full_name}
"""
try:
await callback.message.edit_text(
result_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -1291,12 +1406,213 @@ async def confirm_broadcast(
]),
parse_mode="HTML"
)
except TelegramBadRequest as e:
error_msg = str(e).lower()
if "message to edit not found" in error_msg or "there is no text" in error_msg or "message can't be edited" in error_msg:
# Сообщение удалено или это медиа - отправляем новое
await callback.bot.send_message(
chat_id=callback.message.chat.id,
text=result_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
]),
parse_mode="HTML"
)
else:
raise
await state.clear()
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})")
async def get_target_users_count(db: AsyncSession, target: str) -> int:
"""Быстрый подсчёт пользователей через SQL COUNT вместо загрузки всех в память."""
from sqlalchemy import func as sql_func, distinct
from datetime import datetime, timedelta
base_filter = User.status == UserStatus.ACTIVE.value
if target == "all":
query = select(sql_func.count(User.id)).where(base_filter)
result = await db.execute(query)
return result.scalar() or 0
if target == "active":
# Активные платные подписки (не триал)
query = (
select(sql_func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == False,
)
)
result = await db.execute(query)
return result.scalar() or 0
if target == "trial":
# Триальные подписки (без проверки is_active, как в оригинале)
query = (
select(sql_func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
Subscription.is_trial == True,
)
)
result = await db.execute(query)
return result.scalar() or 0
if target == "no":
# Без активной подписки - используем NOT EXISTS для корректности
subquery = (
select(Subscription.id)
.where(
Subscription.user_id == User.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.exists()
)
query = (
select(sql_func.count(User.id))
.where(base_filter, ~subquery)
)
result = await db.execute(query)
return result.scalar() or 0
if target == "expiring":
# Истекающие в ближайшие 3 дня
now = datetime.utcnow()
expiry_threshold = now + timedelta(days=3)
query = (
select(sql_func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date <= expiry_threshold,
Subscription.end_date > now,
)
)
result = await db.execute(query)
return result.scalar() or 0
if target == "expiring_subscribers":
# Истекающие в ближайшие 7 дней
now = datetime.utcnow()
expiry_threshold = now + timedelta(days=7)
query = (
select(sql_func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date <= expiry_threshold,
Subscription.end_date > now,
)
)
result = await db.execute(query)
return result.scalar() or 0
if target == "expired":
# Истекшие подписки
now = datetime.utcnow()
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
and_(Subscription.id == None, User.has_had_paid_subscription == True),
)
)
)
result = await db.execute(query)
return result.scalar() or 0
if target == "expired_subscribers":
# То же что и expired
now = datetime.utcnow()
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
and_(Subscription.id == None, User.has_had_paid_subscription == True),
)
)
)
result = await db.execute(query)
return result.scalar() or 0
if target == "active_zero":
# Активные платные с нулевым трафиком
query = (
select(sql_func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == False,
or_(Subscription.traffic_used_gb == None, Subscription.traffic_used_gb <= 0),
)
)
result = await db.execute(query)
return result.scalar() or 0
if target == "trial_zero":
# Триальные с нулевым трафиком
query = (
select(sql_func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
Subscription.is_trial == True,
Subscription.status == SubscriptionStatus.ACTIVE.value,
or_(Subscription.traffic_used_gb == None, Subscription.traffic_used_gb <= 0),
)
)
result = await db.execute(query)
return result.scalar() or 0
if target == "zero":
# Все активные с нулевым трафиком
query = (
select(sql_func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
Subscription.status == SubscriptionStatus.ACTIVE.value,
or_(Subscription.traffic_used_gb == None, Subscription.traffic_used_gb <= 0),
)
)
result = await db.execute(query)
return result.scalar() or 0
# Фильтр по тарифу
if target.startswith("tariff_"):
tariff_id = int(target.split("_")[1])
query = (
select(sql_func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.tariff_id == tariff_id,
)
)
result = await db.execute(query)
return result.scalar() or 0
# Для остальных фильтров (custom_ и неизвестные) - fallback на старый метод
users = await get_target_users(db, target)
return len(users)
@@ -1500,6 +1816,17 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
if user.last_activity and user.last_activity < threshold
]
# Фильтр по тарифу
if target.startswith("tariff_"):
tariff_id = int(target.split("_")[1])
return [
user
for user in users
if user.subscription
and user.subscription.is_active
and user.subscription.tariff_id == tariff_id
]
return []
@@ -1639,6 +1966,10 @@ def get_target_name(target_type: str) -> str:
"custom_referrals": "Через рефералов",
"custom_direct": "Прямая регистрация"
}
# Обработка фильтра по тарифу
if target_type.startswith("tariff_"):
tariff_id = target_type.split("_")[1]
return f"По тарифу #{tariff_id}"
return names.get(target_type, target_type)
@@ -1656,6 +1987,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_pinned_broadcast_now, F.data.startswith("admin_pinned_broadcast_now:"))
dp.callback_query.register(handle_pinned_broadcast_skip, F.data.startswith("admin_pinned_broadcast_skip:"))
dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"]))
dp.callback_query.register(show_tariff_filter, F.data == "broadcast_by_tariff")
dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_"))
dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast")
+12
View File
@@ -135,6 +135,16 @@ def _status_info(
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
if record.method == PaymentMethod.FREEKASSA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"error": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
@@ -158,6 +168,8 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {"pending", "waiting_for_capture"}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {"active"}
if record.method == PaymentMethod.FREEKASSA:
return status in {"pending", ""}
return False
+10 -6
View File
@@ -364,11 +364,12 @@ def _format_core_summary(lang_code: str) -> str:
def _get_period_items(lang_code: str) -> List[PriceItem]:
from app.config import PERIOD_PRICES
items: List[PriceItem] = []
for days in settings.get_available_subscription_periods():
key = f"PRICE_{days}_DAYS"
if hasattr(settings, key):
price = getattr(settings, key)
price = PERIOD_PRICES.get(days, 0)
if price > 0:
items.append((key, _format_period_label(days, lang_code), price))
return items
@@ -610,8 +611,9 @@ def _build_period_options_section(language: str) -> Tuple[str, types.InlineKeybo
lang_code = _language_code(language)
suffix = "д" if lang_code == "ru" else "d"
available_subscription = set(settings.get_available_subscription_periods())
available_renewal = set(settings.get_available_renewal_periods())
# Используем методы без фильтрации по ценам для админки
available_subscription = set(settings.get_configured_subscription_periods())
available_renewal = set(settings.get_configured_renewal_periods())
subscription_options = (14, 30, 60, 90, 180, 360)
renewal_options = (30, 60, 90, 180, 360)
@@ -1335,11 +1337,13 @@ async def toggle_period_option(
texts = get_texts(db_user.language)
if target == "subscription":
current = set(settings.get_available_subscription_periods())
# Используем метод без фильтрации по ценам для админки
current = set(settings.get_configured_subscription_periods())
options = {14, 30, 60, 90, 180, 360}
setting_key = "AVAILABLE_SUBSCRIPTION_PERIODS"
elif target == "renewal":
current = set(settings.get_available_renewal_periods())
# Используем метод без фильтрации по ценам для админки
current = set(settings.get_configured_renewal_periods())
options = {30, 60, 90, 180, 360}
setting_key = "AVAILABLE_RENEWAL_PERIODS"
else:
File diff suppressed because it is too large Load Diff
+393 -1
View File
@@ -1063,12 +1063,16 @@ async def _render_user_subscription_overview(
)
])
# Кнопка смены тарифа в режиме тарифов
# Кнопки тарифов в режиме тарифов
if settings.is_tariffs_mode():
keyboard.append([
types.InlineKeyboardButton(
text="📦 Сменить тариф",
callback_data=f"admin_sub_change_tariff_{user_id}"
),
types.InlineKeyboardButton(
text="💳 Купить тариф",
callback_data=f"admin_tariff_buy_{user_id}"
)
])
@@ -4983,6 +4987,366 @@ async def admin_buy_subscription_execute(
await db.rollback()
# ==================== Покупка тарифа администратором ====================
@admin_required
@error_handler
async def admin_buy_tariff(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показывает список тарифов для покупки админом."""
user_id = int(callback.data.split('_')[-1])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
# Получаем доступные тарифы
from app.database.crud.tariff import get_tariffs_for_user
tariffs = await get_tariffs_for_user(db, target_user)
if not tariffs:
await callback.message.edit_text(
"❌ <b>Нет доступных тарифов</b>\n\n"
"Создайте тарифы в разделе управления тарифами.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
return
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
text = f"💳 <b>Покупка тарифа для пользователя</b>\n\n"
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n"
text += "📦 <b>Выберите тариф:</b>\n\n"
for tariff in tariffs:
traffic = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
prices = tariff.period_prices or {}
min_price = min(prices.values()) if prices else 0
text += f"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 от {settings.format_price(min_price)}\n"
keyboard = []
for tariff in tariffs:
keyboard.append([
types.InlineKeyboardButton(
text=tariff.name,
callback_data=f"admin_tariff_buy_select_{user_id}_{tariff.id}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def admin_buy_tariff_period(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показывает выбор периода для тарифа."""
parts = callback.data.split('_')
user_id = int(parts[4])
tariff_id = int(parts[5])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("❌ Тариф недоступен", show_alert=True)
return
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
text = f"💳 <b>Покупка тарифа для пользователя</b>\n\n"
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n"
text += f"📦 <b>Тариф: {tariff.name}</b>\n"
text += f"📊 Трафик: {traffic}\n"
text += f"📱 Устройств: {tariff.device_limit}\n"
text += f"🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n"
text += "Выберите период:"
prices = tariff.period_prices or {}
keyboard = []
for period_str, price in sorted(prices.items(), key=lambda x: int(x[0])):
period = int(period_str)
keyboard.append([
types.InlineKeyboardButton(
text=f"{period} дней — {settings.format_price(price)}",
callback_data=f"admin_tariff_buy_confirm_{user_id}_{tariff_id}_{period}_{price}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="⬅️ К тарифам", callback_data=f"admin_tariff_buy_{user_id}")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def admin_buy_tariff_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Подтверждение покупки тарифа."""
parts = callback.data.split('_')
user_id = int(parts[4])
tariff_id = int(parts[5])
period = int(parts[6])
price_kopeks = int(parts[7])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("❌ Тариф недоступен", show_alert=True)
return
# Проверяем баланс
if target_user.balance_kopeks < price_kopeks:
missing = price_kopeks - target_user.balance_kopeks
await callback.message.edit_text(
f"❌ <b>Недостаточно средств</b>\n\n"
f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n"
f"💳 Стоимость: {settings.format_price(price_kopeks)}\n"
f"📉 Не хватает: {settings.format_price(missing)}\n\n"
f"Пополните баланс пользователя перед покупкой.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
return
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
text = f"💳 <b>Подтверждение покупки тарифа</b>\n\n"
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n"
text += f"📦 <b>Тариф: {tariff.name}</b>\n"
text += f"📊 Трафик: {traffic}\n"
text += f"📱 Устройств: {tariff.device_limit}\n"
text += f"📅 Период: {period} дней\n"
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
text += "Подтвердить покупку?"
keyboard = [
[
types.InlineKeyboardButton(
text="✅ Подтвердить",
callback_data=f"admin_tariff_buy_exec_{user_id}_{tariff_id}_{period}_{price_kopeks}"
)
],
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}"
)
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def admin_buy_tariff_execute(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Выполняет покупку тарифа для пользователя."""
parts = callback.data.split('_')
user_id = int(parts[4])
tariff_id = int(parts[5])
period = int(parts[6])
price_kopeks = int(parts[7])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("❌ Тариф недоступен", show_alert=True)
return
# Проверяем баланс ещё раз
if target_user.balance_kopeks < price_kopeks:
await callback.answer("❌ Недостаточно средств на балансе", show_alert=True)
return
try:
from app.database.crud.user import subtract_user_balance
from app.database.crud.subscription import extend_subscription, create_paid_subscription, get_subscription_by_user_id
from app.database.crud.transaction import create_transaction
from app.services.subscription_service import SubscriptionService
# Списываем баланс
success = await subtract_user_balance(
db, target_user, price_kopeks,
f"Покупка тарифа {tariff.name} на {period} дней (администратор)"
)
if not success:
await callback.answer("❌ Ошибка списания средств", show_alert=True)
return
# Получаем серверы из тарифа
squads = tariff.allowed_squads or []
# Проверяем есть ли подписка
existing_subscription = await get_subscription_by_user_id(db, target_user.id)
if existing_subscription:
# Продлеваем существующую подписку
subscription = await extend_subscription(
db,
existing_subscription,
days=period,
tariff_id=tariff.id,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
)
else:
# Создаем новую подписку
subscription = await create_paid_subscription(
db=db,
user_id=target_user.id,
duration_days=period,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
tariff_id=tariff.id,
)
# Обновляем в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка тарифа (администратор)",
)
except Exception as e:
logger.error(f"Ошибка обновления Remnawave: {e}")
# Создаем транзакцию
await create_transaction(
db,
user_id=target_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=-price_kopeks,
description=f"Покупка тарифа {tariff.name} на {period} дней (администратор)",
)
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
await callback.message.edit_text(
f"✅ <b>Тариф успешно куплен!</b>\n\n"
f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
f"📦 Тариф: {tariff.name}\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"📅 Период: {period} дней\n"
f"💰 Списано: {settings.format_price(price_kopeks)}\n"
f"📅 Действует до: {format_datetime(subscription.end_date)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
]),
parse_mode="HTML"
)
# Уведомляем пользователя
try:
if callback.bot:
await callback.bot.send_message(
chat_id=target_user.telegram_id,
text=f"💳 <b>Администратор оформил вам тариф</b>\n\n"
f"📦 Тариф: {tariff.name}\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"📅 Период: {period} дней\n"
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
f"📅 Действует до: {format_datetime(subscription.end_date)}",
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления пользователю: {e}")
await callback.answer("✅ Тариф куплен!", show_alert=True)
except Exception as e:
logger.error(f"Ошибка покупки тарифа администратором: {e}", exc_info=True)
await callback.answer("❌ Ошибка при покупке тарифа", show_alert=True)
await db.rollback()
@admin_required
@error_handler
async def change_subscription_type_confirm(
@@ -5247,6 +5611,13 @@ async def confirm_admin_tariff_change(
subscription.connected_squads = tariff.allowed_squads or []
subscription.updated_at = datetime.utcnow()
# Сбрасываем докупленный трафик при смене тарифа
from app.database.models import TrafficPurchase
from sqlalchemy import delete as sql_delete
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
await db.commit()
# Синхронизируем с RemnaWave
@@ -5674,6 +6045,27 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_buy_sub_execute_")
)
# Регистрация обработчиков для покупки тарифа администратором
dp.callback_query.register(
admin_buy_tariff,
F.data.startswith("admin_tariff_buy_") & ~F.data.startswith("admin_tariff_buy_select_") & ~F.data.startswith("admin_tariff_buy_confirm_") & ~F.data.startswith("admin_tariff_buy_exec_")
)
dp.callback_query.register(
admin_buy_tariff_period,
F.data.startswith("admin_tariff_buy_select_")
)
dp.callback_query.register(
admin_buy_tariff_confirm,
F.data.startswith("admin_tariff_buy_confirm_")
)
dp.callback_query.register(
admin_buy_tariff_execute,
F.data.startswith("admin_tariff_buy_exec_")
)
# Регистрация обработчиков для фильтрации пользователей
dp.callback_query.register(
show_users_filters,
+1 -1
View File
@@ -85,7 +85,7 @@ async def start_cryptobot_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
+1 -1
View File
@@ -74,7 +74,7 @@ async def start_heleket_payment(
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
+42 -6
View File
@@ -120,7 +120,7 @@ async def route_payment_by_method(
return False
def get_quick_amount_buttons(language: str, user: User) -> list:
async def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
@@ -135,17 +135,41 @@ def get_quick_amount_buttons(language: str, user: User) -> list:
return []
from app.localization.texts import get_texts
from app.config import PERIOD_PRICES
texts = get_texts(language)
# В режиме тарифов получаем цены из тарифа пользователя
tariff_prices = None
tariff_periods = None
if settings.is_tariffs_mode():
from app.database.database import AsyncSessionLocal
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.tariff import get_tariff_by_id
async with AsyncSessionLocal() as db:
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
tariff_periods = sorted(tariff_prices.keys())
buttons = []
periods = settings.get_available_subscription_periods()
periods = periods[:6] # Limit to 6 periods
# Используем периоды тарифа в режиме тарифов, иначе стандартные
if tariff_periods:
periods = tariff_periods[:6]
else:
periods = settings.get_available_subscription_periods()[:6]
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
base_price_kopeks = getattr(settings, price_attr)
# Получаем цену из тарифа или из PERIOD_PRICES
if tariff_prices and period in tariff_prices:
base_price_kopeks = tariff_prices[period]
else:
base_price_kopeks = PERIOD_PRICES.get(period, 0)
if base_price_kopeks > 0:
# Calculate price with user's promo group discount using unified system
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
@@ -366,7 +390,19 @@ async def show_payment_methods(
try:
# Получаем цены для текущих параметров
from app.config import PERIOD_PRICES
from app.database.crud.tariff import get_tariff_by_id
# В режиме тарифов берём цену из тарифа пользователя
base_price_original = 0
if settings.is_tariffs_mode() and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
base_price_original = tariff.period_prices.get(str(duration_days), 0)
# Если не нашли в тарифе - используем PERIOD_PRICES
if base_price_original <= 0:
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,
+2 -2
View File
@@ -66,7 +66,7 @@ async def start_mulenpay_payment(
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -160,7 +160,7 @@ async def process_mulenpay_payment_amount(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
language=db_user.language,
)
+2 -2
View File
@@ -48,7 +48,7 @@ async def _send_pal24_payment_message(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
language=db_user.language,
payment_method=payment_method,
)
@@ -309,7 +309,7 @@ async def start_pal24_payment(
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
+2 -2
View File
@@ -83,7 +83,7 @@ async def _prompt_amount(
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -280,7 +280,7 @@ async def process_platega_payment_amount(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
language=db_user.language,
payment_method_code=method_code,
)
+1 -1
View File
@@ -59,7 +59,7 @@ async def start_stars_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
+2 -2
View File
@@ -64,7 +64,7 @@ async def start_wata_payment(
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -140,7 +140,7 @@ async def process_wata_payment_amount(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
language=db_user.language,
)
except Exception as error: # pragma: no cover - handled by decorator logs
+2 -2
View File
@@ -69,7 +69,7 @@ async def start_yookassa_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -140,7 +140,7 @@ async def start_yookassa_sbp_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
+18
View File
@@ -11,6 +11,18 @@ from app.keyboards.inline import get_back_keyboard
logger = logging.getLogger(__name__)
async def handle_delete_ban_notification(
callback: types.CallbackQuery,
):
"""Удаляет уведомление о бане при нажатии на кнопку"""
try:
await callback.message.delete()
await callback.answer("Уведомление удалено")
except Exception as e:
logger.warning(f"Не удалось удалить уведомление: {e}")
await callback.answer("Не удалось удалить", show_alert=False)
async def handle_unknown_callback(
callback: types.CallbackQuery,
db_user: User
@@ -100,6 +112,12 @@ async def show_rules(
def register_handlers(dp: Dispatcher):
# Удаление уведомлений о банах
dp.callback_query.register(
handle_delete_ban_notification,
F.data == "ban_notify:delete"
)
dp.callback_query.register(
show_rules,
F.data == "menu_rules"
+34 -2
View File
@@ -1092,7 +1092,7 @@ async def handle_back_to_menu(
)
await callback.answer()
def _get_subscription_status(user: User, texts) -> str:
def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -> str:
subscription = getattr(user, "subscription", None)
if not subscription:
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
@@ -1144,6 +1144,10 @@ def _get_subscription_status(user: User, texts) -> str:
)
if actual_status == "active":
# Для суточных тарифов не показываем предупреждение об истечении
if is_daily_tariff:
return texts.t("SUB_STATUS_DAILY_ACTIVE", "💎 Активна")
if days_left > 7 and end_date_text:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
@@ -1185,10 +1189,37 @@ def _insert_random_message(base_text: str, random_message: str, action_prompt: s
async def get_main_menu_text(user, texts, db: AsyncSession):
from app.config import settings
# Загружаем информацию о тарифе если включен режим тарифов
tariff = None
is_daily_tariff = False
tariff_info_block = ""
subscription = getattr(user, "subscription", None)
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
try:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
is_daily_tariff = getattr(tariff, 'is_daily', False)
# Формируем краткий блок информации о тарифе для главного меню
tariff_info_block = f"\n📦 Тариф: {tariff.name}"
except Exception as e:
logger.debug(f"Не удалось загрузить тариф для главного меню: {e}")
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
subscription_status=_get_subscription_status(user, texts, is_daily_tariff)
)
# Добавляем информацию о тарифе перед "Выберите действие"
if tariff_info_block:
action_prompt_text = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
if action_prompt_text in base_text:
base_text = base_text.replace(
action_prompt_text,
f"{tariff_info_block}\n\n{action_prompt_text}"
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
@@ -1392,6 +1423,7 @@ async def handle_activate_button(
except Exception as e:
logger.error(f"Ошибка автоматической активации для {db_user.telegram_id}: {e}")
await db.rollback()
await callback.answer(
texts.t("ACTIVATION_ERROR", "❌ Ошибка активации. Попробуйте позже."),
show_alert=True,
+251 -1
View File
@@ -14,6 +14,232 @@ from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
async def _handle_wheel_spin_payment(
message: types.Message,
db: AsyncSession,
user,
stars_amount: int,
payload: str,
texts,
):
"""Обработка Stars платежа для колеса удачи."""
from app.services.wheel_service import wheel_service
from app.database.crud.wheel import get_or_create_wheel_config, get_wheel_prizes
try:
config = await get_or_create_wheel_config(db)
if not config.is_enabled:
await message.answer(
"❌ Колесо удачи временно недоступно. Звезды будут возвращены.",
)
return False
# Выполняем спин напрямую (оплата уже прошла через Stars)
prizes = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
await message.answer(
"❌ Призы не настроены. Обратитесь в поддержку.",
)
return False
# Рассчитываем стоимость в копейках для статистики
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
payment_value_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
# Рассчитываем вероятности и выбираем приз
prizes_with_probs = wheel_service.calculate_prize_probabilities(config, prizes, payment_value_kopeks)
selected_prize = wheel_service._select_prize(prizes_with_probs)
# Применяем приз
generated_promocode = await wheel_service._apply_prize(db, user, selected_prize, config)
# Создаем запись спина
from app.database.crud.wheel import create_wheel_spin
from app.database.models import WheelSpinPaymentType
promocode_id = None
if generated_promocode:
result = await db.execute(
f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'"
)
row = result.fetchone()
if row:
promocode_id = row[0]
logger.info(
f"🎰 Creating wheel spin: user.id={user.id}, user.telegram_id={user.telegram_id}, "
f"prize={selected_prize.display_name}"
)
spin = await create_wheel_spin(
db=db,
user_id=user.id,
prize_id=selected_prize.id,
payment_type=WheelSpinPaymentType.TELEGRAM_STARS.value,
payment_amount=stars_amount,
payment_value_kopeks=payment_value_kopeks,
prize_type=selected_prize.prize_type,
prize_value=selected_prize.prize_value,
prize_display_name=selected_prize.display_name,
prize_value_kopeks=selected_prize.prize_value_kopeks,
generated_promocode_id=promocode_id,
is_applied=True,
)
logger.info(f"🎰 Wheel spin created: spin.id={spin.id}, spin.user_id={spin.user_id}")
# Ensure all changes are committed (subscription days, traffic GB, etc.)
await db.commit()
# Отправляем результат
prize_message = wheel_service._get_prize_message(selected_prize, generated_promocode)
emoji = selected_prize.emoji or "🎁"
await message.answer(
f"🎰 <b>Колесо удачи!</b>\n\n"
f"{emoji} <b>{selected_prize.display_name}</b>\n\n"
f"{prize_message}\n\n"
f"⭐ Потрачено: {stars_amount} Stars",
parse_mode="HTML",
)
logger.info(
f"🎰 Wheel spin via Stars: user={user.id}, prize={selected_prize.display_name}, "
f"stars={stars_amount}"
)
return True
except Exception as e:
logger.error(f"Ошибка обработки wheel spin payment: {e}", exc_info=True)
await message.answer(
"❌ Произошла ошибка при обработке спина. Обратитесь в поддержку.",
)
return False
async def _handle_trial_payment(
message: types.Message,
db: AsyncSession,
user,
stars_amount: int,
payload: str,
texts,
):
"""Обработка Stars платежа для платного триала."""
from app.database.crud.subscription import activate_pending_trial_subscription
from app.services.subscription_service import SubscriptionService
from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType, PaymentMethod
try:
# Парсим payload: trial_{subscription_id}
parts = payload.split("_")
if len(parts) < 2:
logger.error(f"Невалидный trial payload: {payload}")
await message.answer(
"❌ Ошибка: неверный формат платежа. Обратитесь в поддержку.",
)
return False
try:
subscription_id = int(parts[1])
except ValueError:
logger.error(f"Невалидный subscription_id в trial payload: {payload}")
await message.answer(
"❌ Ошибка: неверный ID подписки. Обратитесь в поддержку.",
)
return False
# Рассчитываем стоимость в копейках
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
# Создаём транзакцию
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=amount_kopeks,
description=f"Оплата пробной подписки через Telegram Stars ({stars_amount} ⭐)",
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=f"trial_stars_{subscription_id}",
is_completed=True,
)
# Активируем pending триальную подписку
subscription = await activate_pending_trial_subscription(
db=db,
subscription_id=subscription_id,
user_id=user.id,
)
if not subscription:
logger.error(f"Не удалось активировать триальную подписку {subscription_id} для пользователя {user.id}")
# Возвращаем деньги на баланс
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
amount_kopeks,
"Возврат за неудачную активацию триала",
transaction_type=TransactionType.REFUND,
)
await message.answer(
"❌ Не удалось активировать пробную подписку. Средства возвращены на баланс.",
)
return False
# Создаем пользователя в RemnaWave
subscription_service = SubscriptionService()
try:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as rw_error:
logger.error(f"Ошибка создания пользователя RemnaWave для триала: {rw_error}")
# Не откатываем подписку, просто логируем - RemnaWave может быть временно недоступен
await db.commit()
await db.refresh(user)
# Отправляем уведомление админам
try:
admin_notification_service = AdminNotificationService(message.bot)
await admin_notification_service.send_trial_activation_notification(
user=user,
subscription=subscription,
paid_amount=amount_kopeks,
payment_method="Telegram Stars",
)
except Exception as admin_error:
logger.warning(f"Ошибка отправки уведомления админам о триале: {admin_error}")
# Отправляем сообщение пользователю
await message.answer(
f"🎉 <b>Пробная подписка активирована!</b>\n\n"
f"⭐ Потрачено: {stars_amount} Stars\n"
f"📅 Период: {settings.TRIAL_DURATION_DAYS} дней\n"
f"📱 Устройств: {subscription.device_limit}\n\n"
f"Используйте меню для подключения к VPN.",
parse_mode="HTML",
)
logger.info(
f"✅ Платный триал активирован через Stars: user={user.id}, "
f"subscription={subscription.id}, stars={stars_amount}"
)
return True
except Exception as e:
logger.error(f"Ошибка обработки trial payment: {e}", exc_info=True)
await message.answer(
"❌ Произошла ошибка при активации пробной подписки. Обратитесь в поддержку.",
)
return False
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
@@ -22,7 +248,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}"
)
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_", "wheel_spin_", "trial_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
@@ -109,6 +335,30 @@ async def handle_successful_payment(
)
return
# Обработка оплаты спина колеса удачи
if payment.invoice_payload and payment.invoice_payload.startswith("wheel_spin_"):
await _handle_wheel_spin_payment(
message=message,
db=db,
user=user,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
texts=texts,
)
return
# Обработка оплаты платного триала
if payment.invoice_payload and payment.invoice_payload.startswith("trial_"):
await _handle_trial_payment(
message=message,
db=db,
user=user,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
texts=texts,
)
return
payment_service = PaymentService(message.bot)
state_data = await state.get_data()
+4 -1
View File
@@ -555,7 +555,10 @@ async def select_country(
await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
return
period_base_price = PERIOD_PRICES[data['period_days']]
period_base_price = PERIOD_PRICES.get(data['period_days'], 0)
if period_base_price <= 0:
await callback.answer("❌ Цена для этого периода не настроена", show_alert=True)
return
discounted_base_price, _ = apply_percentage_discount(
period_base_price,
+51 -1
View File
@@ -95,7 +95,9 @@ async def _prepare_subscription_summary(
months_in_period = calculate_months_from_days(summary_data['period_days'])
period_display = format_period_description(summary_data['period_days'], db_user.language)
base_price_original = PERIOD_PRICES[summary_data['period_days']]
base_price_original = PERIOD_PRICES.get(summary_data['period_days'], 0)
if base_price_original <= 0:
raise ValueError(f"Цена для периода {summary_data['period_days']} дней не настроена")
period_discount_percent = db_user.get_promo_discount(
"period",
summary_data['period_days'],
@@ -508,6 +510,54 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
if subscription_cost > 0:
info_text += f"\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}"
# Отображаем докупленный трафик
if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов
from app.database.models import TrafficPurchase
from sqlalchemy import select as sql_select
from datetime import datetime
now = datetime.utcnow()
purchases_query = (
sql_select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
.where(TrafficPurchase.expires_at > now)
.order_by(TrafficPurchase.expires_at.asc())
)
purchases_result = await db.execute(purchases_query)
purchases = purchases_result.scalars().all()
if purchases:
info_text += "\n\n📦 <b>Докупленный трафик:</b>"
for purchase in purchases:
time_remaining = purchase.expires_at - now
days_remaining = max(0, int(time_remaining.total_seconds() / 86400))
# Генерируем прогресс-бар
total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds()
elapsed_seconds = (now - purchase.created_at).total_seconds()
progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0))
bar_length = 10
filled = int((progress_percent / 100) * bar_length)
bar = "" * filled + "" * (bar_length - filled)
# Форматируем дату истечения
expire_date = purchase.expires_at.strftime("%d.%m.%Y")
# Формируем текст о времени
if days_remaining == 0:
time_text = "истекает сегодня"
elif days_remaining == 1:
time_text = "остался 1 день"
elif days_remaining < 5:
time_text = f"осталось {days_remaining} дня"
else:
time_text = f"осталось {days_remaining} дней"
info_text += f"\n{purchase.traffic_gb} ГБ — {time_text}"
info_text += f"\n {bar} {progress_percent:.0f}% | до {expire_date}"
if (
subscription_url
and subscription_url != "Генерируется..."
+296 -14
View File
@@ -2,6 +2,8 @@ import base64
import json
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
@@ -134,7 +136,7 @@ from app.handlers.simple_subscription import (
_get_simple_subscription_payment_keyboard,
)
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, update_traffic_prices
from .autopay import (
handle_autopay_menu,
handle_subscription_cancel,
@@ -333,25 +335,110 @@ async def show_subscription_info(
else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов")
)
# Получаем название тарифа для режима тарифов
tariff_line = ""
# Получаем информацию о тарифе для режима тарифов
tariff_info_block = ""
tariff = None
if settings.is_tariffs_mode() and subscription.tariff_id:
try:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
tariff_line = f"\n📦 Тариф: {tariff.name}"
except Exception as e:
logger.warning(f"Ошибка получения тарифа: {e}")
# Прикрепляем тариф к подписке для использования в клавиатуре
subscription.tariff = tariff
# Формируем блок информации о тарифе
is_daily = getattr(tariff, 'is_daily', False)
tariff_type_str = "🔄 Суточный" if is_daily else "📅 Периодный"
tariff_info_lines = [
f"<b>📦 {tariff.name}</b>",
f"Тип: {tariff_type_str}",
f"Трафик: {tariff.traffic_limit_gb} ГБ" if tariff.traffic_limit_gb > 0 else "Трафик: ∞ Безлимит",
f"Устройства: {tariff.device_limit}",
]
if is_daily:
# Для суточного тарифа показываем цену и прогресс-бар
daily_price = getattr(tariff, 'daily_price_kopeks', 0) / 100
tariff_info_lines.append(f"Цена: {daily_price:.2f} ₽/день")
# Прогресс-бар до следующего списания
last_charge = getattr(subscription, 'last_daily_charge_at', None)
is_paused = getattr(subscription, 'is_daily_paused', False)
if is_paused:
tariff_info_lines.append("")
tariff_info_lines.append("⏸️ <b>Подписка приостановлена</b>")
# Показываем оставшееся время даже при паузе
if last_charge:
from datetime import timedelta
next_charge = last_charge + timedelta(hours=24)
now = datetime.utcnow()
if next_charge > now:
time_until = next_charge - now
hours_left = time_until.seconds // 3600
minutes_left = (time_until.seconds % 3600) // 60
tariff_info_lines.append(f"⏳ Осталось: {hours_left}ч {minutes_left}мин")
tariff_info_lines.append("💤 Списание приостановлено")
elif last_charge:
from datetime import timedelta
next_charge = last_charge + timedelta(hours=24)
now = datetime.utcnow()
if next_charge > now:
time_until = next_charge - now
hours_left = time_until.seconds // 3600
minutes_left = (time_until.seconds % 3600) // 60
# Процент оставшегося времени (24 часа = 100%)
total_seconds = 24 * 3600
remaining_seconds = time_until.total_seconds()
percent = min(100, max(0, (remaining_seconds / total_seconds) * 100))
# Генерируем прогресс-бар
bar_length = 10
filled = int(bar_length * percent / 100)
empty = bar_length - filled
progress_bar = "" * filled + "" * empty
tariff_info_lines.append("")
tariff_info_lines.append(f"⏳ До списания: {hours_left}ч {minutes_left}мин")
tariff_info_lines.append(f"[{progress_bar}] {percent:.0f}%")
else:
tariff_info_lines.append("")
tariff_info_lines.append("⏳ Первое списание скоро")
tariff_info_block = "\n<blockquote expandable>" + "\n".join(tariff_info_lines) + "</blockquote>"
except Exception as e:
logger.warning(f"Ошибка получения тарифа: {e}", exc_info=True)
# Определяем, суточный ли тариф для выбора шаблона
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточных тарифов другой шаблон без "Действует до" и "Осталось"
message_template = texts.t(
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE",
"""👤 {full_name}
💰 Баланс: {balance}
📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}
📱 Информация о подписке
🎭 Тип: {subscription_type}
📈 Трафик: {traffic}
🌍 Серверы: {servers}
📱 Устройства: {devices_used} / {device_limit}""",
)
else:
message_template = texts.t(
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
"""👤 {full_name}
💰 Баланс: {balance}
📱 Подписка: {status_emoji} {status_display}{warning}
📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}
📱 Информация о подписке
🎭 Тип: {subscription_type}{tariff_line}
🎭 Тип: {subscription_type}
📅 Действует до: {end_date}
Осталось: {time_left}
📈 Трафик: {traffic}
@@ -380,8 +467,8 @@ async def show_subscription_info(
status_emoji=status_emoji,
status_display=status_display,
warning=warning_text,
tariff_info_block=tariff_info_block,
subscription_type=subscription_type,
tariff_line=tariff_line,
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
time_left=time_left_text,
traffic=traffic_used_display,
@@ -405,6 +492,58 @@ async def show_subscription_info(
message += f"{device_info}\n"
message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "</blockquote>")
# Отображаем докупленный трафик
if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов
from app.database.models import TrafficPurchase
from sqlalchemy import select as sql_select
now = datetime.utcnow()
purchases_query = (
sql_select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
.where(TrafficPurchase.expires_at > now)
.order_by(TrafficPurchase.expires_at.asc())
)
purchases_result = await db.execute(purchases_query)
purchases = purchases_result.scalars().all()
if purchases:
message += "\n\n" + texts.t(
"SUBSCRIPTION_PURCHASED_TRAFFIC_TITLE",
"<blockquote>📦 <b>Докупленный трафик:</b>\n",
)
for purchase in purchases:
time_remaining = purchase.expires_at - now
days_remaining = max(0, int(time_remaining.total_seconds() / 86400))
# Генерируем прогресс-бар
total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds()
elapsed_seconds = (now - purchase.created_at).total_seconds()
progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0))
bar_length = 10
filled = int((progress_percent / 100) * bar_length)
bar = "" * filled + "" * (bar_length - filled)
# Форматируем дату истечения
expire_date = purchase.expires_at.strftime("%d.%m.%Y")
# Формируем текст о времени
if days_remaining == 0:
time_text = "истекает сегодня"
elif days_remaining == 1:
time_text = "остался 1 день"
elif days_remaining < 5:
time_text = f"осталось {days_remaining} дня"
else:
time_text = f"осталось {days_remaining} дней"
message += f"{purchase.traffic_gb} ГБ — {time_text}\n"
message += f" {bar} {progress_percent:.0f}% | до {expire_date}\n"
message += texts.t("SUBSCRIPTION_PURCHASED_TRAFFIC_FOOTER", "</blockquote>")
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
@@ -1649,6 +1788,16 @@ async def confirm_extend_subscription(
days = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
# Валидация что период доступен для продления
available_renewal_periods = settings.get_available_renewal_periods()
if days not in available_renewal_periods:
await callback.answer(
texts.t("RENEWAL_PERIOD_NOT_AVAILABLE", "❌ Этот период больше недоступен для продления"),
show_alert=True
)
return
subscription = db_user.subscription
if not subscription:
@@ -1880,6 +2029,7 @@ async def confirm_extend_subscription(
traffic_was_reset = True
subscription.traffic_limit_gb = fixed_limit
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info(f"🔄 Сброс трафика при продлении: {old_traffic_limit} ГБ → {fixed_limit} ГБ")
await db.commit()
@@ -1992,9 +2142,27 @@ async def select_period(
period_days = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
# Валидация что период доступен
available_periods = settings.get_available_subscription_periods()
if period_days not in available_periods:
await callback.answer(
texts.t("PERIOD_NOT_AVAILABLE", "❌ Этот период больше недоступен"),
show_alert=True
)
return
# Получаем цену с защитой от KeyError
period_price = PERIOD_PRICES.get(period_days, 0)
if period_price <= 0:
await callback.answer(
texts.t("PERIOD_PRICE_NOT_SET", "❌ Цена для этого периода не настроена"),
show_alert=True
)
return
data = await state.get_data()
data['period_days'] = period_days
data['total_price'] = PERIOD_PRICES[period_days]
data['total_price'] = period_price
if settings.is_traffic_fixed():
fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
@@ -2080,9 +2248,18 @@ async def select_devices(
data = await state.get_data()
# Получаем цену периода с защитой от KeyError
period_days = data.get('period_days')
if not period_days or period_days not in PERIOD_PRICES:
await callback.answer(
texts.t("PERIOD_NOT_AVAILABLE", "❌ Период больше недоступен, начните заново"),
show_alert=True
)
return
base_price = (
PERIOD_PRICES[data['period_days']] +
settings.get_traffic_price(data['traffic_gb'])
PERIOD_PRICES.get(period_days, 0) +
settings.get_traffic_price(data.get('traffic_gb', 0))
)
countries = await _get_available_countries(db_user.promo_group_id)
@@ -2198,7 +2375,13 @@ async def confirm_purchase(
# Всегда пересчитываем base_price из PERIOD_PRICES для безопасности
# (не доверяем кэшированным значениям из FSM данных)
base_price_original = PERIOD_PRICES[period_days]
base_price_original = PERIOD_PRICES.get(period_days, 0)
if base_price_original <= 0:
await callback.answer(
texts.t("PERIOD_PRICE_NOT_SET", "❌ Цена для этого периода не настроена"),
show_alert=True
)
return
base_discount_percent = db_user.get_promo_discount(
"period",
period_days,
@@ -3016,7 +3199,12 @@ async def handle_subscription_settings(
await callback.message.edit_text(
settings_text,
reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries, tariff=tariff),
reply_markup=get_updated_subscription_settings_keyboard(
db_user.language,
show_countries,
tariff=tariff,
subscription=subscription
),
parse_mode="HTML"
)
await callback.answer()
@@ -3037,6 +3225,95 @@ async def clear_saved_cart(
await callback.answer("🗑️ Корзина очищена")
# ============== ХЕНДЛЕР ПАУЗЫ СУТОЧНОЙ ПОДПИСКИ ==============
async def handle_toggle_daily_subscription_pause(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Переключает паузу суточной подписки."""
from app.database.crud.subscription import toggle_daily_subscription_pause
from app.database.crud.tariff import get_tariff_by_id
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
await callback.answer(
texts.t("NO_SUBSCRIPTION_ERROR", "❌ У вас нет активной подписки"),
show_alert=True
)
return
# Проверяем что это суточный тариф
tariff = None
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff or not getattr(tariff, 'is_daily', False):
await callback.answer(
texts.t("NOT_DAILY_TARIFF_ERROR", "❌ Эта функция доступна только для суточных тарифов"),
show_alert=True
)
return
# Прикрепляем тариф к подписке для CRUD функций
subscription.tariff = tariff
# Переключаем статус паузы
was_paused = getattr(subscription, 'is_daily_paused', False)
# При возобновлении проверяем баланс
if was_paused:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and db_user.balance_kopeks < daily_price:
await callback.answer(
texts.t(
"INSUFFICIENT_BALANCE_FOR_RESUME",
f"❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}"
),
show_alert=True
)
return
subscription = await toggle_daily_subscription_pause(db, subscription)
if was_paused:
# Была пауза, теперь возобновили
message = texts.t(
"DAILY_SUBSCRIPTION_RESUMED",
"▶️ Подписка возобновлена!"
)
# Синхронизируем с Remnawave - активируем пользователя
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
logger.info(f"✅ Синхронизировано с Remnawave после возобновления суточной подписки {subscription.id}")
except Exception as e:
logger.error(f"Ошибка синхронизации с Remnawave при возобновлении: {e}")
else:
# Была активна, теперь на паузе
message = texts.t(
"DAILY_SUBSCRIPTION_PAUSED",
"⏸️ Подписка приостановлена!"
)
# При паузе можно отключить пользователя в Remnawave (опционально)
# Пока оставляем активным, т.к. пауза - это только остановка списания
await callback.answer(message, show_alert=True)
# Возвращаемся в меню подписки - вызываем show_subscription_info
await db.refresh(db_user)
await show_subscription_info(callback, db_user, db)
# ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ==============
@error_handler
@@ -3993,6 +4270,11 @@ def register_handlers(dp: Dispatcher):
F.data == "subscription_settings"
)
dp.callback_query.register(
handle_toggle_daily_subscription_pause,
F.data == "toggle_daily_subscription_pause"
)
dp.callback_query.register(
handle_no_traffic_packages,
F.data == "no_traffic_packages"
File diff suppressed because it is too large Load Diff
+107 -42
View File
@@ -31,7 +31,7 @@ from app.keyboards.inline import (
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_add_traffic_keyboard, get_add_traffic_keyboard_from_tariff,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
@@ -93,41 +93,9 @@ async def handle_add_traffic(
db: AsyncSession
):
from app.config import settings
from app.database.crud.tariff import get_tariff_by_id
texts = get_texts(db_user.language)
# Проверяем, включена ли функция докупки трафика
if not settings.is_traffic_topup_enabled():
await callback.answer(
texts.t(
"TRAFFIC_TOPUP_DISABLED",
"⚠️ Функция докупки трафика отключена",
),
show_alert=True,
)
return
# В режиме тарифов докупка трафика недоступна
if settings.is_tariffs_mode():
await callback.answer(
texts.t(
"TARIFF_TRAFFIC_TOPUP_DISABLED",
"⚠️ В режиме тарифов докупка трафика недоступна",
),
show_alert=True,
)
return
if settings.is_traffic_topup_blocked():
await callback.answer(
texts.t(
"TRAFFIC_FIXED_MODE",
"⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
),
show_alert=True,
)
return
subscription = db_user.subscription
if not subscription or subscription.is_trial:
@@ -144,6 +112,74 @@ async def handle_add_traffic(
)
return
# Режим тарифов - проверяем настройки тарифа
if settings.is_tariffs_mode() and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff or not tariff.can_topup_traffic():
await callback.answer(
texts.t(
"TARIFF_TRAFFIC_TOPUP_DISABLED",
"⚠️ На вашем тарифе докупка трафика недоступна",
),
show_alert=True,
)
return
# Показываем пакеты из тарифа
current_traffic = subscription.traffic_limit_gb
packages = tariff.get_traffic_topup_packages()
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
prompt_text = texts.t(
"ADD_TRAFFIC_PROMPT",
(
"📈 <b>Добавить трафик к подписке</b>\n\n"
"Текущий лимит: {current_traffic}\n"
"Выберите дополнительный трафик:"
),
).format(current_traffic=texts.format_traffic(current_traffic))
await callback.message.edit_text(
prompt_text,
reply_markup=get_add_traffic_keyboard_from_tariff(
db_user.language,
packages,
subscription.end_date,
traffic_discount_percent,
),
parse_mode="HTML"
)
await callback.answer()
return
# Стандартный режим - проверяем глобальные настройки
if not settings.is_traffic_topup_enabled():
await callback.answer(
texts.t(
"TRAFFIC_TOPUP_DISABLED",
"⚠️ Функция докупки трафика отключена",
),
show_alert=True,
)
return
if settings.is_traffic_topup_blocked():
await callback.answer(
texts.t(
"TRAFFIC_FIXED_MODE",
"⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
),
show_alert=True,
)
return
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
@@ -472,14 +508,29 @@ async def add_traffic(
db_user: User,
db: AsyncSession
):
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
from app.database.crud.tariff import get_tariff_by_id
traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
# Получаем цену: из тарифа или из глобальных настроек
base_price = 0
tariff = None
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
# Режим тарифов - берем цену из тарифа
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.can_topup_traffic():
base_price = tariff.get_traffic_topup_price(traffic_gb) or 0
else:
await callback.answer("⚠️ На вашем тарифе докупка трафика недоступна", show_alert=True)
return
else:
# Стандартный режим
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
base_price = settings.get_traffic_topup_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
@@ -550,13 +601,15 @@ async def add_traffic(
if traffic_gb == 0:
subscription.traffic_limit_gb = 0
# При переходе на безлимит сбрасываем докупленный трафик
# При переходе на безлимит сбрасываем все докупки
from app.database.models import TrafficPurchase
from sqlalchemy import delete
await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
else:
# add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля
await add_subscription_traffic(db, subscription, traffic_gb)
# Записываем докупленный трафик для корректного расчета цены сброса
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
subscription.purchased_traffic_gb = current_purchased + traffic_gb
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
@@ -631,6 +684,14 @@ async def handle_switch_traffic(
await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
return
# Проверяем настройку тарифа
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and not tariff.allow_traffic_topup:
await callback.answer("⚠️ Для вашего тарифа переключение трафика недоступно", show_alert=True)
return
current_traffic = subscription.traffic_limit_gb
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цен
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
@@ -805,8 +866,12 @@ async def execute_switch_traffic(
)
subscription.traffic_limit_gb = new_traffic_gb
# Сбрасываем докупленный трафик при переключении пакета
# Сбрасываем все докупки трафика при переключении пакета
from app.database.models import TrafficPurchase
from sqlalchemy import delete
await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
subscription.updated_at = datetime.utcnow()
await db.commit()
+5 -4
View File
@@ -6,6 +6,7 @@ from aiogram import Dispatcher, types, F, Bot
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from sqlalchemy.ext.asyncio import AsyncSession
from app.utils.timezone import format_local_datetime
from app.database.models import User, Ticket, TicketStatus
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
@@ -310,7 +311,7 @@ async def handle_ticket_message_input(
f"📝 Заголовок: {safe_title}\n"
f"📊 Статус: {ticket.status_emoji} "
f"{texts.t('TICKET_STATUS_OPEN','Открыт')}\n"
f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
f"📅 Создан: {format_local_datetime(ticket.created_at, '%d.%m.%Y %H:%M')}\n"
+ ("📎 Вложение: фото\n" if media_type == 'photo' else "")
)
@@ -566,7 +567,7 @@ async def view_ticket(
f"🎫 Тикет #{ticket.id}\n\n"
f"📝 Заголовок: {ticket.title}\n"
f"📊 Статус: {ticket.status_emoji} {status_text}\n"
f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"📅 Создан: {format_local_datetime(ticket.created_at, '%d.%m.%Y %H:%M')}\n\n"
)
message_blocks: list[str] = []
if ticket.messages:
@@ -574,7 +575,7 @@ async def view_ticket(
for msg in ticket.messages:
sender = "👤 Вы" if msg.is_user_message else "🛠️ Поддержка"
block = (
f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
f"{sender} ({format_local_datetime(msg.created_at, '%d.%m %H:%M')}):\n"
f"{msg.message_text}\n\n"
)
if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo":
@@ -1000,7 +1001,7 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
f"📱 <b>Username:</b> @{username_display}\n"
f"📝 <b>Заголовок:</b> {title or ''}\n"
f"📅 <b>Создан:</b> {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
f"📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, '%d.%m.%Y %H:%M')}\n"
)
# Клавиатура с быстрыми действиями для админов в топике
+6
View File
@@ -1377,6 +1377,12 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="broadcast_trial_zero"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_BROADCAST_TARGET_BY_TARIFF", "📦 По тарифу"),
callback_data="broadcast_by_tariff"
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")]
])
+130 -29
View File
@@ -508,14 +508,18 @@ def get_main_menu_keyboard(
)
# Добавляем кнопку докупки трафика для лимитированных подписок
if (
settings.BUY_TRAFFIC_BUTTON_VISIBLE
and settings.is_traffic_topup_enabled()
and not settings.is_traffic_topup_blocked()
and subscription
and not subscription.is_trial
and (subscription.traffic_limit_gb or 0) > 0
):
# В режиме тарифов проверяем tariff_id (детальная проверка в хендлере)
# В классическом режиме проверяем глобальные настройки
show_traffic_topup = False
if subscription and not subscription.is_trial and (subscription.traffic_limit_gb or 0) > 0:
if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None):
# Режим тарифов - показываем кнопку, проверка настроек тарифа в хендлере
show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE
elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked():
# Классический режим - проверяем глобальные настройки
show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE
if show_traffic_topup:
paired_buttons.append(
InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic")
)
@@ -971,43 +975,63 @@ def get_subscription_keyboard(
if happ_row:
keyboard.append(happ_row)
if not is_trial:
if is_trial:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend")
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
])
else:
# Проверяем, является ли тариф суточным
tariff = getattr(subscription, 'tariff', None) if subscription else None
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточного тарифа показываем кнопку паузы/возобновления
is_paused = getattr(subscription, 'is_daily_paused', False)
if is_paused:
pause_text = texts.t("RESUME_DAILY_BUTTON", "▶️ Возобновить подписку")
else:
pause_text = texts.t("PAUSE_DAILY_BUTTON", "⏸️ Приостановить подписку")
keyboard.append([
InlineKeyboardButton(text=pause_text, callback_data="toggle_daily_subscription_pause")
])
else:
# Для обычного тарифа: [Продлить] [Автоплатеж]
keyboard.append([
InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"),
InlineKeyboardButton(
text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"),
callback_data="subscription_autopay",
)
])
if is_trial:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
])
else:
keyboard.append([
# Ряд: [Настройки] [Тариф] (если режим тарифов)
settings_row = [
InlineKeyboardButton(
text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки подписки"),
text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"),
callback_data="subscription_settings",
)
])
# Кнопка смены тарифа для режима тарифов
]
if settings.is_tariffs_mode() and subscription:
keyboard.append([
# Для суточных тарифов переходим на список тарифов, для обычных - мгновенное переключение
tariff_callback = "tariff_switch" if is_daily_tariff else "instant_switch"
settings_row.append(
InlineKeyboardButton(
text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"),
callback_data="tariff_switch"
text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"),
callback_data=tariff_callback
)
])
)
keyboard.append(settings_row)
# Кнопка докупки трафика для платных подписок
if (
settings.is_traffic_topup_enabled()
and not settings.is_traffic_topup_blocked()
and subscription
and (subscription.traffic_limit_gb or 0) > 0
):
# В режиме тарифов проверяем tariff_id, в классическом - глобальные настройки
show_traffic_topup = False
if subscription and (subscription.traffic_limit_gb or 0) > 0:
if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None):
show_traffic_topup = True
elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked():
show_traffic_topup = True
if show_traffic_topup:
keyboard.append([
InlineKeyboardButton(
text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"),
@@ -1796,6 +1820,80 @@ def get_add_traffic_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_add_traffic_keyboard_from_tariff(
language: str,
packages: dict, # {gb: price_kopeks}
subscription_end_date: datetime = None,
discount_percent: int = 0,
) -> InlineKeyboardMarkup:
"""
Клавиатура для докупки трафика из настроек тарифа.
Args:
language: Язык интерфейса
packages: Словарь {ГБ: цена_в_копейках} из тарифа
subscription_end_date: Дата окончания подписки для расчета цены
discount_percent: Процент скидки
"""
from app.utils.pricing_utils import get_remaining_months
texts = get_texts(language)
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
if not packages:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=texts.t("NO_TRAFFIC_PACKAGES", "❌ Нет доступных пакетов"),
callback_data="no_traffic_packages"
)],
[InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_subscription"
)]
])
buttons = []
# Сортируем пакеты по размеру
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
for gb, price_per_month in sorted_packages:
discounted_per_month, discount_per_month = apply_percentage_discount(
price_per_month,
discount_percent,
)
total_price = discounted_per_month * months_multiplier
total_discount = discount_per_month * months_multiplier
if language == "ru":
text = f"📊 +{gb} ГБ трафика - {total_price // 100}{period_text}"
else:
text = f"📊 +{gb} GB traffic - {total_price // 100}{period_text}"
if discount_percent > 0 and total_discount > 0:
text += f" (скидка {discount_percent}%: -{total_discount // 100}₽)"
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
])
buttons.append([
InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_subscription"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_change_devices_keyboard(
current_devices: int,
language: str = DEFAULT_LANGUAGE,
@@ -2433,6 +2531,7 @@ def get_updated_subscription_settings_keyboard(
language: str = DEFAULT_LANGUAGE,
show_countries_management: bool = True,
tariff=None, # Тариф подписки (если есть - ограничиваем настройки)
subscription=None, # Подписка (для проверки суточной паузы)
) -> InlineKeyboardMarkup:
from app.config import settings
@@ -2442,6 +2541,8 @@ def get_updated_subscription_settings_keyboard(
# Если подписка на тарифе - отключаем страны, модем, трафик
has_tariff = tariff is not None
# Для суточных тарифов кнопка паузы теперь в главном меню подписки
if show_countries_management and not has_tariff:
keyboard.append([
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
+16 -4
View File
@@ -201,6 +201,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
"ADMIN_MAIN_SUPPORT": "🛟 Support",
"ADMIN_MAIN_SYSTEM": "🛠️ System",
"ADMIN_MAIN_TARIFFS": "📦 Tariffs",
"ADMIN_MAIN_TRIALS": "🧪 Trials",
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
@@ -889,6 +890,7 @@
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"BACK": "⬅️ Back",
"BACK_BUTTON": "◀️ Back",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
"BACK_TO_MENU": "🏠 Back to menu",
"BACK_TO_OPEN_TICKETS": "🔴 Open tickets",
@@ -1031,6 +1033,7 @@
"FAQ_PAGE_NOT_AVAILABLE": "This FAQ page is not available.",
"FAQ_PAGE_TITLE": "<b>{title}</b>",
"FAQ_PAGE_UNTITLED": "Untitled",
"FREEKASSA_ENTER_AMOUNT": "💳 <b>Top up via {name}</b>\n\nEnter the amount in rubles.\n\nMinimum: {min_amount}\nMaximum: {max_amount}",
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
"HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ",
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:",
@@ -1140,6 +1143,7 @@
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"PAYMENT_CREATE_ERROR": "❌ Failed to create payment. Please try again later.",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited",
@@ -1397,7 +1401,7 @@
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Subscription expiring!</b>\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Subscription expires in {days_text}!</b>\n\nYour paid subscription ends on {end_date}.\n\n💳 <b>Autopay:</b> {autopay_status}\n\n{action_text}\n",
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
"SUBSCRIPTION_EXTEND": "💎 Extend",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.",
@@ -1420,12 +1424,13 @@
"SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found",
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated",
"SUBSCRIPTION_NO_SERVERS": "No servers",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Settings",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
@@ -1451,6 +1456,7 @@
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
"SUB_STATUS_DAILY_ACTIVE": "💎 Active",
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
"SUB_STATUS_DISABLED": "⚫ Disabled",
"SUB_STATUS_PENDING": "⏳ Pending activation",
@@ -1656,5 +1662,11 @@
"ADMIN_USER_RESTRICTIONS": "⚠️ Restrict",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Top-up restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Subscription purchase/renewal restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal"
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal",
"PAUSE_DAILY_BUTTON": "⏸️ Pause subscription",
"RESUME_DAILY_BUTTON": "▶️ Resume subscription",
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!"
}
+16 -5
View File
@@ -906,6 +906,7 @@
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"BACK": "⬅️ Назад",
"BACK_BUTTON": "◀️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
"BACK_TO_MENU": "🏠 В главное меню",
"BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты",
@@ -942,7 +943,7 @@
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANGE_TARIFF_BUTTON": "📦 Сменить тариф",
"CHANGE_TARIFF_BUTTON": "📦 Тариф",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
@@ -1049,6 +1050,7 @@
"FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.",
"FAQ_PAGE_TITLE": "<b>{title}</b>",
"FAQ_PAGE_UNTITLED": "Без названия",
"FREEKASSA_ENTER_AMOUNT": "💳 <b>Пополнение через {name}</b>\n\nВведите сумму пополнения в рублях.\n\nМинимум: {min_amount}\nМаксимум: {max_amount}",
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
"HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ",
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:",
@@ -1158,6 +1160,7 @@
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"PAYMENT_CREATE_ERROR": "❌ Не удалось создать платёж. Попробуйте позже.",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен",
@@ -1415,7 +1418,7 @@
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
"SUBSCRIPTION_EXTEND": "💎 Продлить",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
@@ -1438,12 +1441,13 @@
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется",
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
@@ -1469,6 +1473,7 @@
"SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
"SUB_STATUS_DAILY_ACTIVE": "💎 Активна",
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
"SUB_STATUS_DISABLED": "⚫ Отключена",
"SUB_STATUS_PENDING": "⏳ Ожидает активации",
@@ -1674,5 +1679,11 @@
"ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Покупка/продление подписки ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать"
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать",
"PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку",
"RESUME_DAILY_BUTTON": "▶️ Возобновить подписку",
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!"
}
+4 -1
View File
@@ -131,6 +131,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Налаштування",
"ADMIN_MAIN_SUPPORT": "🛟 Підтримка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
"ADMIN_MAIN_TARIFFS": "📦 Тарифи",
"ADMIN_MAIN_TRIALS": "🧪 Тріали",
"ADMIN_MAIN_PAYMENTS": "💳 Поповнення",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзери/Підписки",
@@ -1354,7 +1355,8 @@
"SUBSCRIPTION_NOT_FOUND": "❌ Підписку не знайдено",
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас немає активної підписки або посилання ще генерується",
"SUBSCRIPTION_NO_SERVERS": "Немає серверів",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активовано дод. знижку {percent}%. \n\nСумується з іншими знижками!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n<code>{bar}</code>",
@@ -1385,6 +1387,7 @@
"SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ закінчується сьогодні!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ закінчується завтра!",
"SUB_STATUS_DAILY_ACTIVE": "💎 Активна",
"SUB_STATUS_EXPIRED": "🔴 Закінчилася\n📅 {end_date}",
"SUB_STATUS_DISABLED": "⚫ Вимкнена",
"SUB_STATUS_PENDING": "⏳ Очікує активації",
+7 -2
View File
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS":"⚙️设置",
"ADMIN_MAIN_SUPPORT":"🛟支持",
"ADMIN_MAIN_SYSTEM":"🛠️系统",
"ADMIN_MAIN_TARIFFS":"📦套餐",
"ADMIN_MAIN_TRIALS":"🧪试用",
"ADMIN_MAIN_PAYMENTS":"💳充值",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS":"👥用户/订阅",
@@ -1352,7 +1353,8 @@
"SUBSCRIPTION_NOT_FOUND":"❌未找到订阅",
"SUBSCRIPTION_NO_ACTIVE_LINK":"⚠您没有活跃的订阅或链接仍在生成中",
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
@@ -1383,6 +1385,7 @@
"SUB_STATUS_ACTIVE_LONG":"💎活跃\n📅至{end_date}({days}天)",
"SUB_STATUS_ACTIVE_TODAY":"💎活跃\n⚠️今天过期!",
"SUB_STATUS_ACTIVE_TOMORROW":"💎活跃\n⚠️明天过期!",
"SUB_STATUS_DAILY_ACTIVE":"💎活跃",
"SUB_STATUS_EXPIRED":"🔴已过期\n📅{end_date}",
"SUB_STATUS_DISABLED":"⚫已禁用",
"SUB_STATUS_PENDING":"⏳等待激活",
@@ -1680,7 +1683,8 @@
"SUBSCRIPTION_NOT_FOUND":"❌未找到订阅",
"SUBSCRIPTION_NO_ACTIVE_LINK":"⚠您没有活跃的订阅或链接仍在生成中",
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
@@ -1711,6 +1715,7 @@
"SUB_STATUS_ACTIVE_LONG":"💎活跃\n📅至{end_date}({days}天)",
"SUB_STATUS_ACTIVE_TODAY":"💎活跃\n⚠️今天过期!",
"SUB_STATUS_ACTIVE_TOMORROW":"💎活跃\n⚠️明天过期!",
"SUB_STATUS_DAILY_ACTIVE":"💎活跃",
"SUB_STATUS_EXPIRED":"🔴已过期\n📅{end_date}",
"SUB_STATUS_DISABLED":"⚫已禁用",
"SUB_STATUS_PENDING":"⏳等待激活",
+18 -38
View File
@@ -1,17 +1,19 @@
import logging
from typing import Callable, Dict, Any, Awaitable, Optional
from datetime import datetime
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
from aiogram.enums import ChatMemberStatus
from sqlalchemy.ext.asyncio import AsyncSession
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, reactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import SubscriptionStatus
from app.database.models import SubscriptionStatus, User
from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
@@ -23,6 +25,11 @@ logger = logging.getLogger(__name__)
class ChannelCheckerMiddleware(BaseMiddleware):
"""
Middleware для проверки подписки на канал.
ОПТИМИЗИРОВАНО: создаёт максимум одну сессию БД на запрос.
"""
def __init__(self):
self.BAD_MEMBER_STATUS = (
ChatMemberStatus.LEFT,
@@ -55,10 +62,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.debug("❌ telegram_id не найден, пропускаем")
return await handler(event, data)
# Админам разрешаем пропускать проверку подписки, чтобы не блокировать
# работу панели управления даже при отсутствии подписки. Важно делать
# это до обращения к состоянию, чтобы не выполнять лишние операции.
# Админам разрешаем пропускать проверку подписки
if settings.is_admin(telegram_id):
logger.debug(
"✅ Пользователь %s является администратором — пропускаем проверку подписки",
@@ -72,7 +76,6 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if state:
current_state = await state.get_state()
is_reg_process = is_registration_process(event, current_state)
if is_reg_process:
@@ -191,10 +194,10 @@ class ChannelCheckerMiddleware(BaseMiddleware):
payload = parts[1]
data = await state.get_data() or {}
if data.get("pending_start_payload") != payload:
data["pending_start_payload"] = payload
await state.set_data(data)
state_data = await state.get_data() or {}
if state_data.get("pending_start_payload") != payload:
state_data["pending_start_payload"] = payload
await state.set_data(state_data)
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
if bot and message.from_user:
@@ -213,7 +216,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
payload: str,
) -> None:
try:
data = await state.get_data() or {}
state_data = await state.get_data() or {}
except Exception as error:
logger.error(
"❌ Не удалось получить данные состояния для уведомления по кампании %s: %s",
@@ -222,7 +225,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
)
return
if data.get("campaign_notification_sent"):
if state_data.get("campaign_notification_sent"):
return
async for db in get_db():
@@ -246,7 +249,6 @@ class ChannelCheckerMiddleware(BaseMiddleware):
)
if sent:
await state.update_data(campaign_notification_sent=True)
break
except Exception as error:
logger.error(
"❌ Ошибка отправки уведомления о переходе по кампании %s: %s",
@@ -259,40 +261,24 @@ class ChannelCheckerMiddleware(BaseMiddleware):
async def _deactivate_subscription_on_unsubscribe(
self, telegram_id: int, bot: Bot, channel_link: Optional[str]
) -> None:
"""Деактивация подписки при отписке от канала."""
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
logger.debug(
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
telegram_id,
)
return
async for db in get_db():
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user or not user.subscription:
logger.debug(
"⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию",
telegram_id,
)
break
subscription = user.subscription
if subscription.status != SubscriptionStatus.ACTIVE.value:
logger.debug(
"ℹ️ Подписка пользователя %s не активна (status=%s) — пропускаем деактивацию",
telegram_id,
subscription.status,
)
break
if settings.CHANNEL_REQUIRED_FOR_ALL:
pass
elif not subscription.is_trial:
logger.debug(
"ℹ️ Подписка пользователя %s платная, CHANNEL_REQUIRED_FOR_ALL=False — пропускаем деактивацию",
telegram_id,
)
break
await deactivate_subscription(db, subscription)
@@ -324,7 +310,6 @@ class ChannelCheckerMiddleware(BaseMiddleware):
)
channel_kb = get_channel_sub_keyboard(channel_link, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
logger.info(f"📨 Уведомление о деактивации отправлено пользователю {telegram_id}")
except Exception as notify_error:
logger.error(
"❌ Не удалось отправить уведомление о деактивации пользователю %s: %s",
@@ -341,10 +326,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
break
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
"""Реактивация подписки после повторной подписки на канал.
Вызывается только если подписка в статусе DISABLED.
"""
"""Реактивация подписки после повторной подписки на канал."""
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
@@ -356,13 +338,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
subscription = user.subscription
# Реактивируем только DISABLED подписки (деактивированные из-за отписки)
# Тихо выходим если подписка не требует реактивации — без логов
# Реактивируем только DISABLED подписки
if subscription.status != SubscriptionStatus.DISABLED.value:
break
# Проверяем что подписка ещё не истекла
from datetime import datetime
if subscription.end_date and subscription.end_date <= datetime.utcnow():
break
+6 -11
View File
@@ -131,23 +131,18 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware):
cleaned = ZERO_WIDTH_PATTERN.sub("", value)
lower_value = cleaned.lower()
if "@" in cleaned or "" in cleaned:
return True
if any(pattern.search(lower_value) for pattern in LINK_PATTERNS):
return True
if DOMAIN_OBFUSCATION_PATTERN.search(lower_value):
return True
# Убраны жёсткие проверки на @ и паттерны ссылок - слишком много ложных срабатываний
# Теперь проверяем только по настраиваемым ключевым словам из DISPLAY_NAME_BANNED_KEYWORDS
normalized = self._normalize_text(lower_value)
collapsed = COLLAPSE_PATTERN.sub("", normalized)
if "tme" in collapsed:
return True
banned_keywords = settings.get_display_name_banned_keywords()
# Если список пустой - не блокируем никого
if not banned_keywords:
return False
return any(
keyword in normalized or keyword in collapsed
for keyword in banned_keywords
+12 -18
View File
@@ -2,16 +2,19 @@ import logging
from typing import Callable, Dict, Any, Awaitable
from datetime import datetime
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
from aiogram.types import TelegramObject
from app.database.database import get_db
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import SubscriptionStatus
logger = logging.getLogger(__name__)
class SubscriptionStatusMiddleware(BaseMiddleware):
"""
Проверяет статус подписки пользователя.
ВАЖНО: Использует db и db_user из data, которые уже загружены в AuthMiddleware.
Не создаёт дополнительных сессий БД.
"""
async def __call__(
self,
@@ -19,25 +22,17 @@ class SubscriptionStatusMiddleware(BaseMiddleware):
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
# Используем db и user из AuthMiddleware - не создаём новую сессию!
db = data.get('db')
user = data.get('db_user')
telegram_id = None
if isinstance(event, (Message, CallbackQuery)):
telegram_id = event.from_user.id
elif isinstance(event, Update):
if event.message:
telegram_id = event.message.from_user.id
elif event.callback_query:
telegram_id = event.callback_query.from_user.id
if telegram_id:
if db and user and user.subscription:
try:
async for db in get_db():
user = await get_user_by_telegram_id(db, telegram_id)
if user and user.subscription:
current_time = datetime.utcnow()
subscription = user.subscription
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
@@ -45,9 +40,8 @@ class SubscriptionStatusMiddleware(BaseMiddleware):
await db.commit()
logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)")
break
except Exception as e:
logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}")
logger.error(f"Ошибка проверки статуса подписки: {e}")
return await handler(event, data)
+39 -5
View File
@@ -27,7 +27,7 @@ from app.database.models import (
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken, Tariff,
server_squad_promo_groups
)
@@ -75,6 +75,7 @@ class BackupService:
Squad,
ServerSquad,
PromoGroup,
Tariff, # Tariff должен быть ДО Subscription из-за FK
User,
PromoCode,
WelcomeText,
@@ -362,7 +363,7 @@ class BackupService:
"tool": pg_dump_path,
}
logger.warning(
logger.info(
"pg_dump не найден в PATH. Используется ORM-дамп в формате JSON"
)
json_info = await self._dump_postgres_json(staging_dir, include_logs)
@@ -818,14 +819,14 @@ class BackupService:
try:
if clear_existing:
logger.warning("🗑️ Очищаем существующие данные...")
await self._clear_database_tables(db)
await self._clear_database_tables(db, backup_data)
models_for_restore = self._get_models_for_backup(True)
models_by_table = {
model.__tablename__: model for model in models_for_restore
}
pre_restore_tables = {"promo_groups"}
pre_restore_tables = {"promo_groups", "tariffs"}
for table_name in pre_restore_tables:
model = models_by_table.get(table_name)
if not model:
@@ -887,6 +888,9 @@ class BackupService:
restored_tables += 1
logger.info("✅ Таблица %s восстановлена", table_name)
# Flush все изменения перед обновлением реферальных связей
await db.flush()
await self._update_user_referrals(db, backup_data)
assoc_tables, assoc_records = await self._restore_association_tables(
@@ -1200,10 +1204,29 @@ class BackupService:
) -> int:
restored_count = 0
# Кешируем существующие tariff_id для проверки FK
existing_tariff_ids = set()
if table_name == "subscriptions":
try:
result = await db.execute(select(Tariff.id))
existing_tariff_ids = {row[0] for row in result.fetchall()}
logger.info(f"📋 Найдено {len(existing_tariff_ids)} существующих тарифов для валидации FK")
except Exception as e:
logger.warning(f"⚠️ Не удалось получить список тарифов: {e}")
for record_data in records:
try:
processed_data = self._process_record_data(record_data, model, table_name)
# Валидация FK для subscriptions.tariff_id
if table_name == "subscriptions" and "tariff_id" in processed_data:
tariff_id = processed_data.get("tariff_id")
if tariff_id is not None and tariff_id not in existing_tariff_ids:
logger.warning(
f"⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки"
)
processed_data["tariff_id"] = None
primary_key_col = self._get_primary_key_column(model)
if primary_key_col and primary_key_col in processed_data:
@@ -1235,7 +1258,7 @@ class BackupService:
return restored_count
async def _clear_database_tables(self, db: AsyncSession):
async def _clear_database_tables(self, db: AsyncSession, backup_data: Optional[Dict[str, Any]] = None):
tables_order = [
"server_squad_promo_groups",
"ticket_messages", "tickets", "support_audit_logs",
@@ -1247,11 +1270,22 @@ class BackupService:
"mulenpay_payments", "pal24_payments",
"transactions", "welcome_texts", "subscriptions",
"promocodes", "users", "promo_groups",
"tariffs", # tariffs должен очищаться ПОСЛЕ subscriptions (FK зависимость)
"server_squads", "squads", "service_rules",
"system_settings", "web_api_tokens", "monitoring_logs"
]
# Таблицы, которые не нужно очищать если в бекапе нет данных для них
# (чтобы сохранить существующие настройки)
preserve_if_no_backup = {"tariffs", "promo_groups", "server_squads", "squads"}
for table_name in tables_order:
# Проверяем, нужно ли сохранить таблицу
if backup_data and table_name in preserve_if_no_backup:
if not backup_data.get(table_name):
logger.info(f"⏭️ Пропускаем очистку {table_name} (нет данных в бекапе)")
continue
try:
await db.execute(text(f"DELETE FROM {table_name}"))
logger.info(f"🗑️ Очищена таблица {table_name}")
+382
View File
@@ -0,0 +1,382 @@
"""
Сервис для отправки уведомлений от ban системы пользователям
"""
import logging
from typing import Optional, Tuple
from datetime import datetime
from aiogram import Bot
from aiogram.exceptions import TelegramAPIError
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database.models import User
from app.services.remnawave_service import remnawave_service
from app.config import settings
logger = logging.getLogger(__name__)
def get_delete_keyboard() -> InlineKeyboardMarkup:
"""Клавиатура с кнопкой удаления уведомления"""
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🗑 Удалить", callback_data="ban_notify:delete")]
])
class BanNotificationService:
"""Сервис для отправки уведомлений о банах пользователям"""
def __init__(self):
self._bot: Optional[Bot] = None
def set_bot(self, bot: Bot):
"""Установить инстанс бота для отправки сообщений"""
self._bot = bot
async def _find_user_by_identifier(
self,
db: AsyncSession,
user_identifier: str
) -> Optional[User]:
"""
Найти пользователя по email или user_id из Remnawave Panel
Args:
db: Сессия БД
user_identifier: Email или user_id пользователя
Returns:
User или None если не найден
"""
# Сначала пытаемся получить telegram_id через remnawave_service
try:
telegram_id = await remnawave_service.get_telegram_id_by_email(user_identifier)
if telegram_id:
# Ищем пользователя по telegram_id
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user:
return user
except Exception as e:
logger.warning(f"Не удалось получить telegram_id через remnawave: {e}")
# Если не нашли через remnawave, пытаемся искать по email в подписках
# (это может быть полезно если у пользователя есть подписка с таким email)
try:
# Импортируем здесь чтобы избежать циклических импортов
from app.database.models import Subscription
result = await db.execute(
select(User)
.join(Subscription)
.where(Subscription.email == user_identifier)
.limit(1)
)
user = result.scalar_one_or_none()
if user:
return user
except Exception as e:
logger.warning(f"Ошибка поиска пользователя по email в подписках: {e}")
return None
async def send_punishment_notification(
self,
db: AsyncSession,
user_identifier: str,
username: str,
ip_count: int,
limit: int,
ban_minutes: int,
node_name: Optional[str] = None
) -> Tuple[bool, str, Optional[int]]:
"""
Отправить уведомление о блокировке пользователю
Returns:
(success, message, telegram_id)
"""
if not self._bot:
return False, "Бот не инициализирован", None
# Находим пользователя
user = await self._find_user_by_identifier(db, user_identifier)
if not user:
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
return False, f"Пользователь не найден: {user_identifier}", None
# Формируем информацию о ноде (заметно выделяем)
node_info = f"🖥 <b>Нода:</b> <code>{node_name}</code>" if node_name else ""
# Формируем сообщение из настроек
# Используем безопасное форматирование - если {node_info} отсутствует в шаблоне, не будет ошибки
format_vars = {
"ip_count": ip_count,
"limit": limit,
"ban_minutes": ban_minutes,
"node_info": node_info
}
try:
message_text = settings.BAN_MSG_PUNISHMENT.format(**format_vars)
except KeyError:
# Старый шаблон без {node_info} - форматируем без него
message_text = settings.BAN_MSG_PUNISHMENT.format(
ip_count=ip_count,
limit=limit,
ban_minutes=ban_minutes
)
# Добавляем информацию о ноде в конец, если она есть
if node_info:
message_text = message_text.rstrip() + f"\n\n{node_info.rstrip()}"
# Отправляем сообщение с кнопкой удаления
try:
await self._bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=get_delete_keyboard()
)
logger.info(
f"Уведомление о бане отправлено пользователю {username} "
f"(telegram_id: {user.telegram_id})"
)
return True, "Уведомление отправлено", user.telegram_id
except TelegramAPIError as e:
logger.error(
f"Ошибка отправки уведомления пользователю {username} "
f"(telegram_id: {user.telegram_id}): {e}"
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
async def send_enabled_notification(
self,
db: AsyncSession,
user_identifier: str,
username: str
) -> Tuple[bool, str, Optional[int]]:
"""
Отправить уведомление о разблокировке пользователю
Returns:
(success, message, telegram_id)
"""
if not self._bot:
return False, "Бот не инициализирован", None
# Находим пользователя
user = await self._find_user_by_identifier(db, user_identifier)
if not user:
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
return False, f"Пользователь не найден: {user_identifier}", None
# Формируем сообщение из настроек
message_text = settings.BAN_MSG_ENABLED
# Отправляем сообщение с кнопкой удаления
try:
await self._bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=get_delete_keyboard()
)
logger.info(
f"Уведомление о разбане отправлено пользователю {username} "
f"(telegram_id: {user.telegram_id})"
)
return True, "Уведомление отправлено", user.telegram_id
except TelegramAPIError as e:
logger.error(
f"Ошибка отправки уведомления пользователю {username} "
f"(telegram_id: {user.telegram_id}): {e}"
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
async def send_warning_notification(
self,
db: AsyncSession,
user_identifier: str,
username: str,
warning_message: str
) -> Tuple[bool, str, Optional[int]]:
"""
Отправить предупреждение пользователю
Returns:
(success, message, telegram_id)
"""
if not self._bot:
return False, "Бот не инициализирован", None
# Находим пользователя
user = await self._find_user_by_identifier(db, user_identifier)
if not user:
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
return False, f"Пользователь не найден: {user_identifier}", None
# Формируем сообщение из настроек
message_text = settings.BAN_MSG_WARNING.format(
warning_message=warning_message
)
# Отправляем сообщение с кнопкой удаления
try:
await self._bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=get_delete_keyboard()
)
logger.info(
f"Предупреждение отправлено пользователю {username} "
f"(telegram_id: {user.telegram_id})"
)
return True, "Предупреждение отправлено", user.telegram_id
except TelegramAPIError as e:
logger.error(
f"Ошибка отправки предупреждения пользователю {username} "
f"(telegram_id: {user.telegram_id}): {e}"
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
async def send_network_wifi_notification(
self,
db: AsyncSession,
user_identifier: str,
username: str,
ban_minutes: int,
network_type: Optional[str] = None,
node_name: Optional[str] = None
) -> Tuple[bool, str, Optional[int]]:
"""
Отправить уведомление о блокировке за использование WiFi сети
Returns:
(success, message, telegram_id)
"""
if not self._bot:
return False, "Бот не инициализирован", None
# Находим пользователя
user = await self._find_user_by_identifier(db, user_identifier)
if not user:
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
return False, f"Пользователь не найден: {user_identifier}", None
# Формируем сообщение из настроек (заметно выделяем)
network_info = f"├ 🌐 Сеть: <b>{network_type}</b>\n" if network_type else ""
node_info = f"🖥 <b>Нода:</b> <code>{node_name}</code>" if node_name else ""
logger.info(f"WiFi notification: node_name={node_name!r}, node_info={node_info!r}")
# Безопасное форматирование
format_vars = {
"ban_minutes": ban_minutes,
"network_info": network_info,
"node_info": node_info
}
try:
message_text = settings.BAN_MSG_WIFI.format(**format_vars)
except KeyError:
logger.warning("BAN_MSG_WIFI template missing placeholders, adding node_info to end")
message_text = settings.BAN_MSG_WIFI.format(ban_minutes=ban_minutes)
extra_info = (network_info + node_info).strip()
if extra_info:
message_text = message_text.rstrip() + f"\n\n{extra_info}"
# Отправляем сообщение с кнопкой удаления
try:
await self._bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=get_delete_keyboard()
)
logger.info(
f"Уведомление о WiFi бане отправлено пользователю {username} "
f"(telegram_id: {user.telegram_id})"
)
return True, "Уведомление отправлено", user.telegram_id
except TelegramAPIError as e:
logger.error(
f"Ошибка отправки WiFi уведомления пользователю {username} "
f"(telegram_id: {user.telegram_id}): {e}"
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
async def send_network_mobile_notification(
self,
db: AsyncSession,
user_identifier: str,
username: str,
ban_minutes: int,
network_type: Optional[str] = None,
node_name: Optional[str] = None
) -> Tuple[bool, str, Optional[int]]:
"""
Отправить уведомление о блокировке за использование мобильной сети
Returns:
(success, message, telegram_id)
"""
if not self._bot:
return False, "Бот не инициализирован", None
# Находим пользователя
user = await self._find_user_by_identifier(db, user_identifier)
if not user:
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
return False, f"Пользователь не найден: {user_identifier}", None
# Формируем сообщение из настроек (заметно выделяем)
network_info = f"├ 🌐 Сеть: <b>{network_type}</b>\n" if network_type else ""
node_info = f"🖥 <b>Нода:</b> <code>{node_name}</code>" if node_name else ""
# Безопасное форматирование
format_vars = {
"ban_minutes": ban_minutes,
"network_info": network_info,
"node_info": node_info
}
try:
message_text = settings.BAN_MSG_MOBILE.format(**format_vars)
except KeyError:
message_text = settings.BAN_MSG_MOBILE.format(ban_minutes=ban_minutes)
extra_info = (network_info + node_info).strip()
if extra_info:
message_text = message_text.rstrip() + f"\n\n{extra_info}"
# Отправляем сообщение с кнопкой удаления
try:
await self._bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=get_delete_keyboard()
)
logger.info(
f"Уведомление о Mobile бане отправлено пользователю {username} "
f"(telegram_id: {user.telegram_id})"
)
return True, "Уведомление отправлено", user.telegram_id
except TelegramAPIError as e:
logger.error(
f"Ошибка отправки Mobile уведомления пользователю {username} "
f"(telegram_id: {user.telegram_id}): {e}"
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
# Глобальный экземпляр сервиса
ban_notification_service = BanNotificationService()
+485
View File
@@ -0,0 +1,485 @@
"""
Сервис для автоматического списания суточных подписок.
Проверяет подписки с суточным тарифом и списывает плату раз в сутки.
Также сбрасывает докупленный трафик по истечении 30 дней.
"""
import logging
import asyncio
from datetime import datetime
from typing import Optional
from aiogram import Bot
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import get_db
from app.database.crud.subscription import (
get_daily_subscriptions_for_charge,
update_daily_charge_time,
suspend_daily_subscription_insufficient_balance,
)
from app.database.crud.user import subtract_user_balance, get_user_by_id
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType, PaymentMethod, Subscription, User
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
class DailySubscriptionService:
"""
Сервис автоматического списания для суточных подписок.
"""
def __init__(self):
self._running = False
self._bot: Optional[Bot] = None
self._check_interval_minutes = 30 # Проверка каждые 30 минут
def set_bot(self, bot: Bot):
"""Устанавливает бота для отправки уведомлений."""
self._bot = bot
def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис суточных подписок."""
return getattr(settings, 'DAILY_SUBSCRIPTIONS_ENABLED', True)
def get_check_interval_minutes(self) -> int:
"""Возвращает интервал проверки в минутах."""
return getattr(settings, 'DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES', 30)
async def process_daily_charges(self) -> dict:
"""
Обрабатывает суточные списания.
Returns:
dict: Статистика обработки
"""
stats = {
"checked": 0,
"charged": 0,
"suspended": 0,
"errors": 0,
}
try:
async for db in get_db():
subscriptions = await get_daily_subscriptions_for_charge(db)
stats["checked"] = len(subscriptions)
for subscription in subscriptions:
try:
result = await self._process_single_charge(db, subscription)
if result == "charged":
stats["charged"] += 1
elif result == "suspended":
stats["suspended"] += 1
elif result == "error":
stats["errors"] += 1
except Exception as e:
logger.error(
f"Ошибка обработки суточной подписки {subscription.id}: {e}",
exc_info=True
)
stats["errors"] += 1
except Exception as e:
logger.error(f"Ошибка при получении подписок для списания: {e}", exc_info=True)
return stats
async def _process_single_charge(self, db, subscription) -> str:
"""
Обрабатывает списание для одной подписки.
Returns:
str: "charged", "suspended", "error", "skipped"
"""
user = subscription.user
if not user:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.warning(f"Пользователь не найден для подписки {subscription.id}")
return "error"
tariff = subscription.tariff
if not tariff:
logger.warning(f"Тариф не найден для подписки {subscription.id}")
return "error"
daily_price = tariff.daily_price_kopeks
if daily_price <= 0:
logger.warning(f"Некорректная суточная цена для тарифа {tariff.id}")
return "error"
# Проверяем баланс
if user.balance_kopeks < daily_price:
# Недостаточно средств - приостанавливаем подписку
await suspend_daily_subscription_insufficient_balance(db, subscription)
# Уведомляем пользователя
if self._bot:
await self._notify_insufficient_balance(user, subscription, daily_price)
logger.info(
f"Подписка {subscription.id} приостановлена: недостаточно средств "
f"(баланс: {user.balance_kopeks}, требуется: {daily_price})"
)
return "suspended"
# Списываем средства
description = f"Суточная оплата тарифа «{tariff.name}»"
try:
deducted = await subtract_user_balance(
db,
user,
daily_price,
description,
)
if not deducted:
logger.warning(f"Не удалось списать средства для подписки {subscription.id}")
return "error"
# Создаём транзакцию
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=description,
payment_method=PaymentMethod.MANUAL,
)
# Обновляем время последнего списания и продлеваем подписку
subscription = await update_daily_charge_time(db, subscription)
logger.info(
f"✅ Суточное списание: подписка {subscription.id}, "
f"сумма {daily_price} коп., пользователь {user.telegram_id}"
)
# Синхронизируем с Remnawave (обновляем срок подписки)
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.warning(f"Не удалось обновить Remnawave: {e}")
# Уведомляем пользователя
if self._bot:
await self._notify_daily_charge(user, subscription, daily_price)
return "charged"
except Exception as e:
logger.error(
f"Ошибка при списании средств для подписки {subscription.id}: {e}",
exc_info=True
)
return "error"
async def _notify_daily_charge(self, user, subscription, amount_kopeks: int):
"""Уведомляет пользователя о суточном списании."""
if not self._bot:
return
try:
texts = get_texts(getattr(user, "language", "ru"))
amount_rubles = amount_kopeks / 100
balance_rubles = user.balance_kopeks / 100
message = (
f"💳 <b>Суточное списание</b>\n\n"
f"Списано: {amount_rubles:.2f}\n"
f"Остаток баланса: {balance_rubles:.2f}\n\n"
f"Следующее списание через 24 часа."
)
await self._bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML",
)
except Exception as e:
logger.warning(f"Не удалось отправить уведомление о списании: {e}")
async def _notify_insufficient_balance(self, user, subscription, required_amount: int):
"""Уведомляет пользователя о недостатке средств."""
if not self._bot:
return
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
texts = get_texts(getattr(user, "language", "ru"))
required_rubles = required_amount / 100
balance_rubles = user.balance_kopeks / 100
message = (
f"⚠️ <b>Подписка приостановлена</b>\n\n"
f"Недостаточно средств для суточной оплаты.\n\n"
f"Требуется: {required_rubles:.2f}\n"
f"Баланс: {balance_rubles:.2f}\n\n"
f"Пополните баланс, чтобы возобновить подписку."
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text="💳 Пополнить баланс",
callback_data="menu_balance"
)],
[InlineKeyboardButton(
text="📱 Моя подписка",
callback_data="menu_subscription"
)],
]
)
await self._bot.send_message(
chat_id=user.telegram_id,
text=message,
reply_markup=keyboard,
parse_mode="HTML",
)
except Exception as e:
logger.warning(f"Не удалось отправить уведомление о недостатке средств: {e}")
async def process_traffic_resets(self) -> dict:
"""
Сбрасывает докупленный трафик у подписок, у которых истёк срок.
Returns:
dict: Статистика обработки
"""
stats = {
"checked": 0,
"reset": 0,
"errors": 0,
}
from app.database.models import TrafficPurchase
try:
async for db in get_db():
# Находим все истекшие докупки
now = datetime.utcnow()
query = (
select(TrafficPurchase)
.where(TrafficPurchase.expires_at <= now)
)
result = await db.execute(query)
expired_purchases = result.scalars().all()
stats["checked"] = len(expired_purchases)
# Группируем по подпискам для обновления
subscriptions_to_update = {}
for purchase in expired_purchases:
if purchase.subscription_id not in subscriptions_to_update:
subscriptions_to_update[purchase.subscription_id] = []
subscriptions_to_update[purchase.subscription_id].append(purchase)
# Удаляем истекшие докупки и обновляем подписки
for subscription_id, purchases in subscriptions_to_update.items():
try:
await self._reset_subscription_traffic(db, subscription_id, purchases)
stats["reset"] += len(purchases)
except Exception as e:
logger.error(
f"Ошибка сброса трафика подписки {subscription_id}: {e}",
exc_info=True
)
stats["errors"] += 1
except Exception as e:
logger.error(f"Ошибка при получении подписок для сброса трафика: {e}", exc_info=True)
return stats
async def _reset_subscription_traffic(self, db: AsyncSession, subscription_id: int, expired_purchases: list):
"""Сбрасывает истекшие докупки трафика у подписки."""
from app.database.models import TrafficPurchase
# Получаем подписку
subscription_query = select(Subscription).where(Subscription.id == subscription_id)
subscription_result = await db.execute(subscription_query)
subscription = subscription_result.scalar_one_or_none()
if not subscription:
return
# Считаем сколько ГБ нужно убрать
total_expired_gb = sum(p.traffic_gb for p in expired_purchases)
old_limit = subscription.traffic_limit_gb
old_purchased = subscription.purchased_traffic_gb or 0
# КРИТИЧЕСКАЯ ПРОВЕРКА: защита от некорректных данных
if total_expired_gb > old_purchased:
logger.error(
f"⚠️ ОШИБКА ДАННЫХ: подписка {subscription.id}, "
f"истекает {total_expired_gb} ГБ, но purchased_traffic_gb = {old_purchased} ГБ. "
f"Сбрасываем только {old_purchased} ГБ."
)
total_expired_gb = old_purchased
# Рассчитываем базовый лимит тарифа (без докупок)
base_limit = old_limit - old_purchased
# Получаем базовый лимит из тарифа для проверки
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
tariff_base_limit = tariff.traffic_limit_gb or 0
# Проверяем, что базовый лимит не отрицательный
if base_limit < 0:
logger.warning(
f"⚠️ Базовый лимит отрицательный для подписки {subscription.id}: {base_limit} ГБ. "
f"Используем лимит из тарифа: {tariff_base_limit} ГБ"
)
base_limit = tariff_base_limit
# Защита от отрицательного базового лимита
base_limit = max(0, base_limit)
# Удаляем истекшие записи
for purchase in expired_purchases:
await db.delete(purchase)
# Рассчитываем новый лимит
new_purchased = old_purchased - total_expired_gb
new_limit = base_limit + new_purchased
# Двойная защита: новый лимит не может быть меньше базового
if new_limit < base_limit:
logger.error(
f"⚠️ КРИТИЧЕСКАЯ ОШИБКА: новый лимит ({new_limit} ГБ) меньше базового ({base_limit} ГБ). "
f"Устанавливаем базовый лимит."
)
new_limit = base_limit
new_purchased = 0
# Обновляем подписку
subscription.traffic_limit_gb = max(0, new_limit)
subscription.purchased_traffic_gb = max(0, new_purchased)
# Проверяем, остались ли активные докупки
now = datetime.utcnow()
remaining_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription_id)
.where(TrafficPurchase.expires_at > now)
)
remaining_result = await db.execute(remaining_query)
remaining_purchases = remaining_result.scalars().all()
if not remaining_purchases:
# Нет больше активных докупок - сбрасываем дату
subscription.traffic_reset_at = None
else:
# Устанавливаем дату сброса по ближайшей истекающей докупке
next_expiry = min(p.expires_at for p in remaining_purchases)
subscription.traffic_reset_at = next_expiry
subscription.updated_at = datetime.utcnow()
await db.commit()
logger.info(
f"🔄 Сброс истекших докупок: подписка {subscription.id}, "
f"было {old_limit} ГБ (базовый: {base_limit} ГБ, докуплено: {old_purchased} ГБ), "
f"стало {subscription.traffic_limit_gb} ГБ (базовый: {base_limit} ГБ, докуплено: {new_purchased} ГБ), "
f"убрано {total_expired_gb} ГБ из {len(expired_purchases)} покупок"
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.warning(f"Не удалось синхронизировать с RemnaWave после сброса трафика: {e}")
# Уведомляем пользователя
if self._bot and subscription.user_id:
user = await get_user_by_id(db, subscription.user_id)
if user:
await self._notify_traffic_reset(user, subscription, total_expired_gb)
async def _notify_traffic_reset(self, user: User, subscription: Subscription, reset_gb: int):
"""Уведомляет пользователя о сбросе докупленного трафика."""
if not self._bot:
return
try:
message = (
f"️ <b>Сброс докупленного трафика</b>\n\n"
f"Ваш докупленный трафик ({reset_gb} ГБ) был сброшен, "
f"так как прошло 30 дней с момента первой докупки.\n\n"
f"Текущий лимит трафика: {subscription.traffic_limit_gb} ГБ\n\n"
f"Вы можете докупить трафик снова в любое время."
)
await self._bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML",
)
except Exception as e:
logger.warning(f"Не удалось отправить уведомление о сбросе трафика: {e}")
async def start_monitoring(self):
"""Запускает периодическую проверку суточных подписок и сброса трафика."""
self._running = True
interval_minutes = self.get_check_interval_minutes()
logger.info(
f"🔄 Запуск сервиса суточных подписок (интервал: {interval_minutes} мин)"
)
while self._running:
try:
# Обработка суточных списаний
stats = await self.process_daily_charges()
if stats["charged"] > 0 or stats["suspended"] > 0:
logger.info(
f"📊 Суточные списания: проверено={stats['checked']}, "
f"списано={stats['charged']}, приостановлено={stats['suspended']}, "
f"ошибок={stats['errors']}"
)
# Обработка сброса докупленного трафика
traffic_stats = await self.process_traffic_resets()
if traffic_stats["reset"] > 0:
logger.info(
f"📊 Сброс трафика: проверено={traffic_stats['checked']}, "
f"сброшено={traffic_stats['reset']}, ошибок={traffic_stats['errors']}"
)
except Exception as e:
logger.error(f"Ошибка в цикле проверки суточных подписок: {e}", exc_info=True)
await asyncio.sleep(interval_minutes * 60)
def stop_monitoring(self):
"""Останавливает периодическую проверку."""
self._running = False
logger.info("⏹️ Сервис суточных подписок остановлен")
# Глобальный экземпляр сервиса
daily_subscription_service = DailySubscriptionService()
__all__ = ["DailySubscriptionService", "daily_subscription_service"]
+131 -8
View File
@@ -4,6 +4,9 @@ import hashlib
import hmac
import time
import logging
import asyncio
import json
import urllib.request
from typing import Optional, Dict, Any, Set
import aiohttp
@@ -12,6 +15,10 @@ from app.config import settings
logger = logging.getLogger(__name__)
# Кэш для публичного IP
_cached_public_ip: Optional[str] = None
_ip_fetch_lock = asyncio.Lock()
# IP-адреса Freekassa для проверки webhook
FREEKASSA_IPS: Set[str] = {
"168.119.157.136",
@@ -22,6 +29,62 @@ FREEKASSA_IPS: Set[str] = {
API_BASE_URL = "https://api.fk.life/v1"
# Сервисы для определения публичного IP (в порядке приоритета)
IP_SERVICES = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
"https://ipinfo.io/ip",
]
async def get_public_ip() -> str:
"""
Получает публичный IP сервера.
1. Сначала проверяет переменную окружения SERVER_PUBLIC_IP
2. Если нет - запрашивает через внешние сервисы и кэширует
"""
global _cached_public_ip
# Проверяем переменную окружения
env_ip = getattr(settings, 'SERVER_PUBLIC_IP', None)
if env_ip:
return env_ip
# Возвращаем кэшированный IP если есть
if _cached_public_ip:
return _cached_public_ip
async with _ip_fetch_lock:
# Повторная проверка после получения блокировки
if _cached_public_ip:
return _cached_public_ip
# Пробуем получить IP от внешних сервисов
async with aiohttp.ClientSession() as session:
for service_url in IP_SERVICES:
try:
async with session.get(
service_url,
timeout=aiohttp.ClientTimeout(total=5)
) as response:
if response.status == 200:
ip = (await response.text()).strip()
# Простая валидация IPv4
if ip and len(ip.split('.')) == 4:
_cached_public_ip = ip
logger.info(f"Определён публичный IP сервера: {ip}")
return ip
except Exception as e:
logger.debug(f"Не удалось получить IP от {service_url}: {e}")
continue
# Fallback на известный рабочий IP если ничего не получилось
fallback_ip = "185.92.183.173"
logger.warning(f"Не удалось определить публичный IP, используем fallback: {fallback_ip}")
_cached_public_ip = fallback_ip
return fallback_ip
class FreekassaService:
"""Сервис для работы с API Freekassa."""
@@ -121,6 +184,7 @@ class FreekassaService:
phone: Optional[str] = None,
payment_system_id: Optional[int] = None,
lang: str = "ru",
ip: Optional[str] = None,
) -> str:
"""
Формирует URL для перенаправления на оплату (форма выбора).
@@ -128,6 +192,60 @@ class FreekassaService:
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
# Специальная обработка для метода оплаты 44 (NSPK), чтобы работало как в старой версии
if ps_id == 44:
try:
# Определяем IP (важно для API запроса) - здесь синхронно, поэтому лучше иметь передачу IP
# Если IP не передан, используем fallback
target_ip = ip or "185.92.183.173"
target_email = email or "test@example.com"
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
"paymentId": str(order_id),
"i": 44,
"email": target_email,
"ip": target_ip,
"amount": final_amount,
"currency": "RUB"
}
# Генерация подписи
params["signature"] = self._generate_api_signature(params)
logger.info(f"Freekassa synchronous build_payment_url for 44: {params}")
data_json = json.dumps(params).encode('utf-8')
req = urllib.request.Request(
f"{API_BASE_URL}/orders/create",
data=data_json,
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=30) as response:
resp_body = response.read().decode('utf-8')
data = json.loads(resp_body)
if data.get("type") == "error":
logger.error(f"Freekassa build_payment_url error: {data}")
# Fallback to standard flow if error? Or raise?
# User wants it to work. Raise to see error is safer.
# raise Exception(f"Freekassa API Error: {data.get('message')}")
# Но чтобы не ломать полностью, можно попробовать вернуть обычную ссылку,
# если API не сработал? Нет, вернем ошибку или ссылку из data.
if data.get("location"):
return data.get("location")
except Exception as e:
logger.error(f"Failed to create order 44 via sync API: {e}")
# Если не получилось, попробуем сгенерировать обычную ссылку как fallback
pass
signature = self.generate_form_signature(final_amount, currency, order_id)
params = {
@@ -144,8 +262,6 @@ class FreekassaService:
if phone:
params["phone"] = phone
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
if ps_id:
params["i"] = ps_id
@@ -177,13 +293,18 @@ class FreekassaService:
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1
target_email = email or "test@example.com"
# Определяем публичный IP сервера
server_ip = ip or await get_public_ip()
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()), # Наносекунды для уникальности
"paymentId": str(order_id),
"i": ps_id,
"email": email or "user@example.com",
"ip": ip or "127.0.0.1",
"email": target_email,
"ip": server_ip,
"amount": final_amount,
"currency": currency,
}
@@ -206,10 +327,12 @@ class FreekassaService:
data = await response.json()
if response.status != 200 or data.get("type") == "error":
# Проверяем на ошибку - API может вернуть error или type=error
error_msg = data.get("error") or data.get("message")
if response.status != 200 or data.get("type") == "error" or error_msg:
logger.error(f"Freekassa create_order error: {data}")
raise Exception(
f"Freekassa API error: {data.get('message', 'Unknown error')}"
f"Freekassa API error: {error_msg or 'Unknown error'}"
)
return data
@@ -255,7 +378,7 @@ class FreekassaService:
}
params["signature"] = self._generate_api_signature(params)
logger.info(f"Freekassa get_order_status params: {params}")
logger.debug(f"Freekassa get_order_status params: {params}")
try:
async with aiohttp.ClientSession() as session:
@@ -266,7 +389,7 @@ class FreekassaService:
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.info(f"Freekassa get_order_status response: {text}")
logger.debug(f"Freekassa get_order_status response: {text}")
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
+19 -1
View File
@@ -66,8 +66,9 @@ class MenuLayoutStatsService:
callback_data: Optional[str] = None,
button_type: Optional[str] = None,
button_text: Optional[str] = None,
) -> ButtonClickLog:
) -> Optional[ButtonClickLog]:
"""Записать клик по кнопке."""
try:
click_log = ButtonClickLog(
button_id=button_id,
user_id=user_id,
@@ -78,6 +79,23 @@ class MenuLayoutStatsService:
db.add(click_log)
await db.commit()
return click_log
except Exception:
# If user doesn't exist (foreign key violation), try without user_id
await db.rollback()
try:
click_log = ButtonClickLog(
button_id=button_id,
user_id=None, # Log without user reference
callback_data=callback_data,
button_type=button_type,
button_text=button_text,
)
db.add(click_log)
await db.commit()
return click_log
except Exception:
await db.rollback()
return None
@classmethod
async def get_button_stats(
+33 -5
View File
@@ -46,6 +46,7 @@ from app.database.models import (
MonitoringLog,
SubscriptionStatus,
Subscription,
Tariff,
User,
Ticket,
TicketStatus,
@@ -530,7 +531,10 @@ class MonitoringService:
)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
and_(
Subscription.is_trial.is_(True),
@@ -694,7 +698,10 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
and_(
Subscription.is_trial == False,
@@ -703,7 +710,14 @@ class MonitoringService:
)
)
subscriptions = result.scalars().all()
all_subscriptions = result.scalars().all()
# Исключаем суточные тарифы - для них отдельная логика
subscriptions = [
sub for sub in all_subscriptions
if not (sub.tariff and getattr(sub.tariff, 'is_daily', False))
]
sent_day1 = 0
sent_wave2 = 0
sent_wave3 = 0
@@ -814,7 +828,10 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
@@ -829,7 +846,18 @@ class MonitoringService:
logger.debug(f"📅 Текущее время: {current_time}")
logger.debug(f"📅 Пороговая дата: {threshold_date}")
subscriptions = result.scalars().all()
all_subscriptions = result.scalars().all()
# Исключаем суточные тарифы - для них отдельная логика списания
subscriptions = [
sub for sub in all_subscriptions
if not (sub.tariff and getattr(sub.tariff, 'is_daily', False))
]
excluded_count = len(all_subscriptions) - len(subscriptions)
if excluded_count > 0:
logger.debug(f"🔄 Исключено {excluded_count} суточных подписок из уведомлений")
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
return subscriptions
+68 -13
View File
@@ -509,17 +509,7 @@ class FreekassaPaymentMixin:
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""
Проверяет статус платежа Freekassa по локальному ID.
Freekassa не предоставляет API для проверки статуса платежа,
поэтому возвращаем текущее состояние из БД.
Args:
db: Сессия БД
local_payment_id: Внутренний ID платежа
Returns:
Dict с информацией о платеже или None если не найден
Проверяет статус платежа Freekassa по локальному ID через API.
"""
freekassa_crud = import_module("app.database.crud.freekassa")
@@ -528,8 +518,73 @@ class FreekassaPaymentMixin:
logger.warning("Freekassa payment not found: id=%s", local_payment_id)
return None
# Freekassa не имеет API для проверки статуса,
# информация приходит только через webhook
if payment.is_paid:
return {
"payment": payment,
"status": "success",
"is_paid": True,
}
if not settings.FREEKASSA_API_KEY:
return {
"payment": payment,
"status": payment.status or "pending",
"is_paid": payment.is_paid,
}
try:
# Запрашиваем статус заказа в Freekassa
response = await freekassa_service.get_order_status(payment.order_id)
# Freekassa возвращает список заказов
orders = response.get("orders", [])
target_order = None
# Ищем наш заказ в списке
for order in orders:
# В ответе API поле называется merchant_order_id, а не paymentId
# Поддерживаем оба варианта на всякий случай
order_key = str(order.get("merchant_order_id") or order.get("paymentId"))
if order_key == str(payment.order_id):
target_order = order
break
if target_order:
# Статус 1 = Оплачен
fk_status = int(target_order.get("status", 0))
if fk_status == 1:
logger.info("Freekassa payment %s confirmed via API", payment.order_id)
callback_payload = {
"check_source": "api",
"fk_order_data": target_order,
}
# ID заказа на стороне FK (fk_order_id или id)
fk_intid = str(target_order.get("fk_order_id") or target_order.get("id"))
# Обновляем статус
payment = await freekassa_crud.update_freekassa_payment_status(
db=db,
payment=payment,
status="success",
is_paid=True,
freekassa_order_id=fk_intid,
payment_system_id=int(target_order.get("curID")) if target_order.get("curID") else None,
callback_payload=callback_payload,
)
# Финализируем
await self._finalize_freekassa_payment(
db,
payment,
intid=fk_intid,
trigger="api_check",
)
except Exception as e:
logger.error("Error checking Freekassa payment status: %s", e)
return {
"payment": payment,
"status": payment.status or "pending",
+97 -2
View File
@@ -110,6 +110,18 @@ class YooKassaPaymentMixin:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata.copy() if metadata else {}
# Всегда добавляем telegram_id в метаданные для возможности возврата платежа
if "user_telegram_id" not in payment_metadata:
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user and user.telegram_id:
payment_metadata["user_telegram_id"] = str(user.telegram_id)
payment_metadata["user_username"] = user.username or ""
except Exception as e:
logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}")
payment_metadata.update(
{
"user_id": str(user_id),
@@ -201,6 +213,18 @@ class YooKassaPaymentMixin:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata.copy() if metadata else {}
# Всегда добавляем telegram_id в метаданные для возможности возврата платежа
if "user_telegram_id" not in payment_metadata:
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user and user.telegram_id:
payment_metadata["user_telegram_id"] = str(user.telegram_id)
payment_metadata["user_username"] = user.username or ""
except Exception as e:
logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}")
payment_metadata.update(
{
"user_id": str(user_id),
@@ -548,16 +572,20 @@ class YooKassaPaymentMixin:
payment_description = getattr(payment, "description", "YooKassa платеж")
payment_purpose = payment_metadata.get("payment_purpose", "")
payment_type = payment_metadata.get("type", "")
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
is_trial_payment = payment_type == "trial"
transaction_type = (
TransactionType.SUBSCRIPTION_PAYMENT
if is_simple_subscription
if is_simple_subscription or is_trial_payment
else TransactionType.DEPOSIT
)
transaction_description = (
f"Оплата подписки через YooKassa: {payment_description}"
if is_simple_subscription
else f"Оплата пробной подписки через YooKassa: {payment_description}"
if is_trial_payment
else f"Пополнение через YooKassa: {payment_description}"
)
@@ -590,7 +618,74 @@ class YooKassaPaymentMixin:
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
if is_simple_subscription:
if is_trial_payment:
# Обработка платного триала
logger.info(
"YooKassa платеж %s обработан как оплата триала. Баланс пользователя %s не изменяется.",
payment.yookassa_payment_id,
user.id,
)
try:
subscription_id = payment_metadata.get("subscription_id")
if subscription_id:
from app.database.crud.subscription import activate_pending_trial_subscription
from app.services.subscription_service import SubscriptionService
from app.services.admin_notification_service import AdminNotificationService
subscription = await activate_pending_trial_subscription(
db=db,
subscription_id=int(subscription_id),
user_id=user.id,
)
if subscription:
logger.info(f"Триальная подписка {subscription_id} активирована для пользователя {user.id}")
# Создаем пользователя в RemnaWave
subscription_service = SubscriptionService()
try:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as rw_error:
logger.error(f"Ошибка создания RemnaWave для триала: {rw_error}")
# Уведомление админам
if getattr(self, "bot", None):
try:
admin_notification_service = AdminNotificationService(self.bot)
await admin_notification_service.send_trial_activation_notification(
user=user,
subscription=subscription,
paid_amount=payment.amount_kopeks,
payment_method="YooKassa",
)
except Exception as admin_error:
logger.warning(f"Ошибка уведомления админов о триале: {admin_error}")
# Уведомление пользователю
if getattr(self, "bot", None):
try:
from app.config import settings
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
f"🎉 <b>Пробная подписка активирована!</b>\n\n"
f"💳 Оплачено: {settings.format_price(payment.amount_kopeks)}\n"
f"📅 Период: {settings.TRIAL_DURATION_DAYS} дней\n"
f"📱 Устройств: {subscription.device_limit}\n\n"
f"Используйте меню для подключения к VPN."
),
parse_mode="HTML",
)
except Exception as notify_error:
logger.warning(f"Ошибка уведомления пользователя о триале: {notify_error}")
else:
logger.error(f"Не удалось активировать триал {subscription_id} для {user.id}")
else:
logger.error(f"Отсутствует subscription_id в metadata триального платежа YooKassa")
except Exception as trial_error:
logger.error(f"Ошибка обработки триального платежа YooKassa: {trial_error}", exc_info=True)
elif is_simple_subscription:
logger.info(
"YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.",
payment.yookassa_payment_id,
+57
View File
@@ -1894,6 +1894,59 @@ class RemnaWaveService:
logger.error(f"Ошибка получения статистики трафика для пользователя {telegram_id}: {e}")
return None
async def get_telegram_id_by_email(self, user_identifier: str) -> Optional[int]:
"""
Получить telegram_id пользователя по email или username из панели RemnaWave.
Args:
user_identifier: Email или username пользователя
Returns:
telegram_id если найден, иначе None
"""
if not self.is_configured:
logger.warning("RemnaWave API не настроен для поиска пользователя")
return None
try:
async with self.get_api_client() as api:
# Сначала пробуем найти по username (часто username == email)
try:
user = await api.get_user_by_username(user_identifier)
if user and user.telegram_id:
logger.info(
f"Найден пользователь по username '{user_identifier}': "
f"telegram_id={user.telegram_id}"
)
return user.telegram_id
except Exception as e:
logger.debug(f"Пользователь не найден по username '{user_identifier}': {e}")
# Если не нашли по username, ищем по email среди всех пользователей
try:
all_users_response = await api.get_all_users(start=0, size=10000)
users_list = all_users_response.get('users', [])
for panel_user in users_list:
panel_email = panel_user.email if hasattr(panel_user, 'email') else None
if panel_email and panel_email.lower() == user_identifier.lower():
panel_telegram_id = panel_user.telegram_id if hasattr(panel_user, 'telegram_id') else None
if panel_telegram_id:
logger.info(
f"Найден пользователь по email '{user_identifier}': "
f"telegram_id={panel_telegram_id}"
)
return panel_telegram_id
except Exception as e:
logger.warning(f"Ошибка поиска пользователя по email '{user_identifier}': {e}")
logger.warning(f"Пользователь с идентификатором '{user_identifier}' не найден в панели")
return None
except Exception as e:
logger.error(f"Ошибка получения telegram_id для '{user_identifier}': {e}")
return None
async def test_api_connection(self) -> Dict[str, Any]:
if not self.is_configured:
return {
@@ -2689,3 +2742,7 @@ class RemnaWaveService:
"api_url": settings.REMNAWAVE_API_URL,
"attempts_used": attempts,
}
# Singleton instance for backward compatibility
remnawave_service = RemnaWaveService()
+2 -1
View File
@@ -558,7 +558,8 @@ class ReportingService:
select(func.count(func.distinct(Subscription.user_id))).where(
or_(
Subscription.connected_squads.is_(None),
func.jsonb_array_length(cast(Subscription.connected_squads, JSONB)) == 0,
cast(Subscription.connected_squads, JSONB) == cast('[]', JSONB),
func.jsonb_typeof(cast(Subscription.connected_squads, JSONB)) != 'array',
)
)
)
@@ -1160,6 +1160,8 @@ async def auto_activate_subscription_after_topup(
exc_info=True,
)
return (False, False)
await db.rollback()
return False
__all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"]
@@ -336,9 +336,11 @@ class MiniAppSubscriptionPurchaseService:
currency = (getattr(user, "balance_currency", None) or "RUB").upper()
texts = get_texts(getattr(user, "language", None))
# Exclude trial-only servers from purchase options
available_servers = await get_available_server_squads(
db,
promo_group_id=getattr(user, "promo_group_id", None),
exclude_trial_only=True,
)
server_catalog: Dict[str, ServerSquad] = {server.squad_uuid: server for server in available_servers}
+42 -5
View File
@@ -62,9 +62,17 @@ def _resolve_addon_discount_percent(
period_days=period_days,
)
def get_traffic_reset_strategy():
def get_traffic_reset_strategy(tariff=None):
"""Получает стратегию сброса трафика.
Args:
tariff: Объект тарифа. Если у тарифа задан traffic_reset_mode,
используется он, иначе глобальная настройка из конфига.
Returns:
TrafficLimitStrategy: Стратегия сброса трафика для RemnaWave API.
"""
from app.config import settings
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
strategy_mapping = {
'NO_RESET': 'NO_RESET',
@@ -73,6 +81,16 @@ def get_traffic_reset_strategy():
'MONTH': 'MONTH'
}
# Проверяем настройку тарифа
if tariff is not None:
tariff_mode = getattr(tariff, 'traffic_reset_mode', None)
if tariff_mode is not None:
mapped_strategy = strategy_mapping.get(tariff_mode.upper(), 'NO_RESET')
logger.info(f"🔄 Стратегия сброса трафика из тарифа '{getattr(tariff, 'name', 'N/A')}': {tariff_mode} -> {mapped_strategy}")
return getattr(TrafficLimitStrategy, mapped_strategy)
# Используем глобальную настройку
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET')
logger.info(f"🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}")
return getattr(TrafficLimitStrategy, mapped_strategy)
@@ -205,7 +223,7 @@ class SubscriptionService:
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(),
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
@@ -242,7 +260,7 @@ class SubscriptionService:
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(),
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
telegram_id=user.telegram_id,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
@@ -326,7 +344,7 @@ class SubscriptionService:
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(),
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
@@ -413,6 +431,25 @@ class SubscriptionService:
logger.error(f"Ошибка включения RemnaWave пользователя: {e}")
return False
async def get_remnawave_squads(self) -> Optional[List[dict]]:
"""Получить список internal squads из RemnaWave."""
try:
async with self.get_api_client() as api:
squads = await api.get_internal_squads()
# Преобразуем в формат для sync_with_remnawave
result = []
for squad in squads:
result.append({
'uuid': squad.uuid,
'name': squad.name,
})
logger.info(f"✅ Получено {len(result)} серверов из RemnaWave")
return result
except Exception as e:
logger.error(f"Ошибка получения серверов из RemnaWave: {e}")
return None
async def revoke_subscription(
self,
db: AsyncSession,
+32
View File
@@ -127,6 +127,7 @@ class BotConfigurationService:
"LOG": "📝 Логирование",
"DEBUG": "🧪 Режим разработки",
"MODERATION": "🛡️ Модерация и фильтры",
"BAN_NOTIFICATIONS": "🚫 Тексты уведомлений о блокировках",
}
CATEGORY_DESCRIPTIONS: Dict[str, str] = {
@@ -184,6 +185,7 @@ class BotConfigurationService:
"LOG": "Уровни логирования и ротация.",
"DEBUG": "Отладочные функции и безопасный режим.",
"MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.",
"BAN_NOTIFICATIONS": "Тексты уведомлений о блокировках, которые отправляются пользователям.",
}
@staticmethod
@@ -214,6 +216,7 @@ class BotConfigurationService:
"DEVICES_SELECTION_ENABLED": "SUBSCRIPTIONS_CORE",
"DEVICES_SELECTION_DISABLED_AMOUNT": "SUBSCRIPTIONS_CORE",
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
"SALES_MODE": "SUBSCRIPTIONS_CORE",
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
@@ -292,6 +295,7 @@ class BotConfigurationService:
"REMNAWAVE_USER_USERNAME_TEMPLATE": "REMNAWAVE",
"REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE",
"REMNAWAVE_AUTO_SYNC_TIMES": "REMNAWAVE",
"CABINET_REMNA_SUB_CONFIG": "MINIAPP",
}
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
@@ -339,6 +343,7 @@ class BotConfigurationService:
"WEB_API_": "WEB_API",
"DEBUG": "DEBUG",
"DISPLAY_NAME_": "MODERATION",
"BAN_MSG_": "BAN_NOTIFICATIONS",
}
CHOICES: Dict[str, List[ChoiceOption]] = {
@@ -382,6 +387,10 @@ class BotConfigurationService:
ChoiceOption("default", "📋 Полное меню"),
ChoiceOption("text", "📝 Текстовое меню"),
],
"SALES_MODE": [
ChoiceOption("classic", "📋 Классический (периоды из .env)"),
ChoiceOption("tariffs", "📦 Тарифы (из кабинета)"),
],
"SERVER_STATUS_MODE": [
ChoiceOption("disabled", "🚫 Отключено"),
ChoiceOption("external_link", "🌐 Внешняя ссылка"),
@@ -440,6 +449,19 @@ class BotConfigurationService:
}
SETTING_HINTS: Dict[str, Dict[str, str]] = {
"SALES_MODE": {
"description": (
"Режим продажи подписок. "
"«Классический» — выбор периода из .env (PRICE_14_DAYS и т.д.). "
"«Тарифы» — готовые тарифные планы из кабинета с серверами и лимитами."
),
"format": "Выберите один из доступных режимов.",
"example": "tariffs",
"warning": (
"При смене режима логика покупки подписки полностью меняется. "
"В режиме «Тарифы» пользователи выбирают готовый тарифный план."
),
},
"YOOKASSA_ENABLED": {
"description": (
"Включает оплату через YooKassa. "
@@ -678,6 +700,16 @@ class BotConfigurationService:
"warning": "Если тег не задан или невалиден, существующий тег не будет изменён.",
"dependencies": "Оплата подписки и интеграция с RemnaWave",
},
"CABINET_REMNA_SUB_CONFIG": {
"description": (
"UUID конфигурации страницы подписки из RemnaWave. "
"Позволяет синхронизировать список приложений напрямую из панели."
),
"format": "UUID конфигурации из раздела Subscription Page Configs в RemnaWave.",
"example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba",
"warning": "Убедитесь, что конфигурация существует в панели и содержит нужные приложения.",
"dependencies": "Настроенное подключение к RemnaWave API",
},
}
@classmethod
+23 -1
View File
@@ -23,7 +23,7 @@ from app.database.models import (
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
FreekassaPayment, AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
TransactionType
)
from app.config import settings
@@ -960,6 +960,28 @@ class UserService:
except Exception as e:
logger.error(f"❌ Ошибка удаления Heleket платежей: {e}")
# Удаляем Freekassa платежи
try:
freekassa_payments_result = await db.execute(
select(FreekassaPayment).where(FreekassaPayment.user_id == user_id)
)
freekassa_payments = freekassa_payments_result.scalars().all()
if freekassa_payments:
logger.info(f"🔄 Удаляем {len(freekassa_payments)} Freekassa платежей")
await db.execute(
update(FreekassaPayment)
.where(FreekassaPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(
delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления Freekassa платежей: {e}")
try:
transactions_result = await db.execute(
select(Transaction).where(Transaction.user_id == user_id)
+632
View File
@@ -0,0 +1,632 @@
"""
Сервис колеса удачи (Fortune Wheel) с RTP алгоритмом.
"""
import logging
import random
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, List, Tuple, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import (
User,
Subscription,
WheelConfig,
WheelPrize,
WheelSpin,
WheelPrizeType,
WheelSpinPaymentType,
PromoCode,
PromoCodeType,
)
from app.database.crud.wheel import (
get_or_create_wheel_config,
get_wheel_prizes,
get_user_spins_today,
create_wheel_spin,
mark_spin_applied,
get_wheel_statistics,
)
from app.database.crud.user import add_user_balance
from app.database.crud.subscription import get_subscription_by_user_id
logger = logging.getLogger(__name__)
@dataclass
class SpinResult:
"""Результат спина колеса."""
success: bool
prize_id: Optional[int] = None
prize_type: Optional[str] = None
prize_value: int = 0
prize_display_name: str = ""
emoji: str = "🎁"
color: str = "#3B82F6"
rotation_degrees: float = 0.0
message: str = ""
promocode: Optional[str] = None
error: Optional[str] = None
@dataclass
class SpinAvailability:
"""Доступность спина для пользователя."""
can_spin: bool
reason: Optional[str] = None
spins_remaining_today: int = 0
can_pay_stars: bool = False
can_pay_days: bool = False
min_subscription_days: int = 0
user_subscription_days: int = 0
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
class FortuneWheelService:
"""Сервис колеса удачи с RTP механикой."""
def __init__(self):
pass
async def check_availability(
self,
db: AsyncSession,
user: User
) -> SpinAvailability:
"""Проверить доступность спина для пользователя."""
config = await get_or_create_wheel_config(db)
# Колесо выключено
if not config.is_enabled:
return SpinAvailability(
can_spin=False,
reason="wheel_disabled",
)
# Проверяем лимит спинов
spins_today = await get_user_spins_today(db, user.id)
spins_remaining = config.daily_spin_limit - spins_today if config.daily_spin_limit > 0 else 999
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
return SpinAvailability(
can_spin=False,
reason="daily_limit_reached",
spins_remaining_today=0,
)
# Проверяем доступные способы оплаты
can_pay_stars = False
can_pay_days = False
user_subscription_days = 0
required_balance_kopeks = 0
# Проверяем оплату Stars (конвертируется в рубли из баланса)
if config.spin_cost_stars_enabled and config.spin_cost_stars > 0:
stars_rate = Decimal(str(settings.get_stars_rate()))
rubles = Decimal(config.spin_cost_stars) * stars_rate
required_balance_kopeks = int(rubles * 100)
# Проверяем достаточно ли средств на балансе
if user.balance_kopeks >= required_balance_kopeks:
can_pay_stars = True
if config.spin_cost_days_enabled:
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.is_active:
user_subscription_days = subscription.days_left
# Нужно оставить минимум min_subscription_days_for_day_payment дней после оплаты
if user_subscription_days >= config.min_subscription_days_for_day_payment + config.spin_cost_days:
can_pay_days = True
if not can_pay_stars and not can_pay_days:
# Определяем причину
reason = "no_payment_method_available"
if config.spin_cost_stars_enabled and user.balance_kopeks < required_balance_kopeks:
reason = "insufficient_balance"
return SpinAvailability(
can_spin=False,
reason=reason,
spins_remaining_today=spins_remaining,
can_pay_stars=can_pay_stars,
can_pay_days=can_pay_days,
min_subscription_days=config.min_subscription_days_for_day_payment,
user_subscription_days=user_subscription_days,
user_balance_kopeks=user.balance_kopeks,
required_balance_kopeks=required_balance_kopeks,
)
# Проверяем наличие призов
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
return SpinAvailability(
can_spin=False,
reason="no_prizes_configured",
)
return SpinAvailability(
can_spin=True,
spins_remaining_today=spins_remaining,
can_pay_stars=can_pay_stars,
can_pay_days=can_pay_days,
min_subscription_days=config.min_subscription_days_for_day_payment,
user_subscription_days=user_subscription_days,
user_balance_kopeks=user.balance_kopeks,
required_balance_kopeks=required_balance_kopeks,
)
def calculate_prize_probabilities(
self,
config: WheelConfig,
prizes: List[WheelPrize],
spin_cost_kopeks: int
) -> List[Tuple[WheelPrize, float]]:
"""
Рассчитать вероятности выпадения призов на основе RTP.
Алгоритм:
1. Целевая средняя выплата = spin_cost * (RTP / 100)
2. Для призов с manual_probability - используем его напрямую
3. Для остальных - рассчитываем веса обратно пропорционально стоимости приза
4. "Nothing" сектор балансирует систему
"""
if not prizes:
return []
target_payout = spin_cost_kopeks * (config.rtp_percent / 100)
# Разделяем призы с ручной вероятностью и автоматической
manual_prizes = []
auto_prizes = []
manual_prob_sum = 0.0
for prize in prizes:
if prize.manual_probability is not None and prize.manual_probability > 0:
manual_prizes.append((prize, prize.manual_probability))
manual_prob_sum += prize.manual_probability
else:
auto_prizes.append(prize)
# Оставшаяся вероятность для авто-призов
remaining_prob = max(0, 1.0 - manual_prob_sum)
if not auto_prizes or remaining_prob <= 0:
# Только ручные призы, нормализуем их
if manual_prizes:
total = sum(p[1] for p in manual_prizes)
return [(p[0], p[1] / total) for p in manual_prizes]
return []
# Рассчитываем веса для авто-призов
# Вес обратно пропорционален стоимости приза (более дорогие выпадают реже)
weights = []
for prize in auto_prizes:
if prize.prize_value_kopeks > 0:
# Чем дороже приз, тем меньше вес
weight = target_payout / prize.prize_value_kopeks
else:
# "Nothing" или нулевой приз - даем базовый вес
weight = 1.0
weights.append((prize, max(weight, 0.01))) # Минимальный вес 1%
# Нормализуем веса авто-призов до remaining_prob
total_weight = sum(w[1] for w in weights)
auto_probabilities = [
(prize, (weight / total_weight) * remaining_prob)
for prize, weight in weights
]
# Объединяем
result = manual_prizes + auto_probabilities
# Финальная нормализация (на случай погрешностей)
total = sum(p[1] for p in result)
if total > 0:
result = [(p[0], p[1] / total) for p in result]
return result
def _select_prize(
self,
prizes_with_probabilities: List[Tuple[WheelPrize, float]]
) -> WheelPrize:
"""Выбрать приз на основе вероятностей."""
if not prizes_with_probabilities:
raise ValueError("No prizes to select from")
rand = random.random()
cumulative = 0.0
for prize, probability in prizes_with_probabilities:
cumulative += probability
if rand <= cumulative:
return prize
# Fallback на последний приз
return prizes_with_probabilities[-1][0]
def _calculate_rotation(
self,
prizes: List[WheelPrize],
selected_prize: WheelPrize
) -> float:
"""
Рассчитать угол поворота колеса для анимации.
Возвращает градусы для CSS transform.
"""
if not prizes:
return 0.0
# Находим индекс выбранного приза
prize_index = next(
(i for i, p in enumerate(prizes) if p.id == selected_prize.id),
0
)
# Угол одного сектора
sector_angle = 360 / len(prizes)
# Базовый угол до центра сектора (от 12 часов по часовой)
base_angle = prize_index * sector_angle + sector_angle / 2
# Добавляем случайное смещение внутри сектора (не по краям)
offset = random.uniform(-sector_angle * 0.3, sector_angle * 0.3)
# Угол остановки (стрелка сверху, поэтому инвертируем)
stop_angle = 360 - base_angle + offset
# Добавляем несколько полных оборотов для эффекта
full_rotations = random.randint(5, 8) * 360
return full_rotations + stop_angle
async def _process_stars_payment(
self,
db: AsyncSession,
user: User,
config: WheelConfig
) -> int:
"""
Обработать оплату Stars (списание эквивалента с баланса).
Возвращает стоимость в копейках.
"""
# Конвертируем Stars в рубли
stars_rate = Decimal(str(settings.get_stars_rate()))
rubles = Decimal(config.spin_cost_stars) * stars_rate
kopeks = int(rubles * 100)
if user.balance_kopeks < kopeks:
raise ValueError("Недостаточно средств на балансе")
# Списываем с баланса
user.balance_kopeks -= kopeks
logger.info(f"💫 Списано {kopeks/100:.2f}₽ ({config.spin_cost_stars}⭐) с баланса user_id={user.id}")
return kopeks
async def _process_days_payment(
self,
db: AsyncSession,
user: User,
config: WheelConfig
) -> int:
"""
Обработать оплату днями подписки.
Возвращает эквивалент в копейках.
"""
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription or not subscription.is_active:
raise ValueError("Нет активной подписки")
if subscription.days_left < config.min_subscription_days_for_day_payment + config.spin_cost_days:
raise ValueError("Недостаточно дней подписки")
# Уменьшаем end_date
subscription.end_date -= timedelta(days=config.spin_cost_days)
subscription.updated_at = datetime.utcnow()
# Оцениваем стоимость в копейках (для статистики)
# Берем цену 30-дневного периода и делим на 30
from app.config import PERIOD_PRICES
price_30_days = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS) or 19900
daily_price = price_30_days / 30
kopeks = int(daily_price * config.spin_cost_days)
logger.info(f"📅 Списано {config.spin_cost_days} дней подписки у user_id={user.id}")
return kopeks
async def _apply_prize(
self,
db: AsyncSession,
user: User,
prize: WheelPrize,
config: WheelConfig
) -> Optional[str]:
"""
Применить приз к пользователю.
Возвращает промокод (если приз - промокод), иначе None.
"""
prize_type = prize.prize_type
if prize_type == WheelPrizeType.NOTHING.value:
logger.info(f"🎰 Пустой приз для user_id={user.id}")
return None
if prize_type == WheelPrizeType.BALANCE_BONUS.value:
# Пополнение баланса
await add_user_balance(
db, user, prize.prize_value,
description=f"Выигрыш в колесе удачи: {prize.prize_value/100:.2f}",
create_transaction=True,
)
logger.info(f"💰 Начислено {prize.prize_value/100:.2f}₽ на баланс user_id={user.id}")
return None
if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value:
# Дни подписки
subscription = await get_subscription_by_user_id(db, user.id)
if subscription:
subscription.end_date += timedelta(days=prize.prize_value)
subscription.updated_at = datetime.utcnow()
logger.info(f"📅 Начислено {prize.prize_value} дней подписки user_id={user.id}")
else:
# Если нет подписки - начисляем на баланс эквивалент
await add_user_balance(
db, user, prize.prize_value_kopeks,
description=f"Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)",
create_transaction=True,
)
logger.info(f"💰 Дни конвертированы в баланс для user_id={user.id}")
return None
if prize_type == WheelPrizeType.TRAFFIC_GB.value:
# Бонусный трафик
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.traffic_limit_gb > 0:
subscription.traffic_limit_gb += prize.prize_value
subscription.updated_at = datetime.utcnow()
logger.info(f"📊 Начислено {prize.prize_value}GB трафика user_id={user.id}")
else:
# Если безлимит или нет подписки - на баланс
await add_user_balance(
db, user, prize.prize_value_kopeks,
description=f"Выигрыш в колесе удачи: {prize.prize_value}GB (на баланс)",
create_transaction=True,
)
return None
if prize_type == WheelPrizeType.PROMOCODE.value:
# Генерация промокода
promocode = await self._generate_prize_promocode(db, user, prize, config)
logger.info(f"🎟️ Сгенерирован промокод {promocode.code} для user_id={user.id}")
return promocode.code
return None
async def _generate_prize_promocode(
self,
db: AsyncSession,
user: User,
prize: WheelPrize,
config: WheelConfig
) -> PromoCode:
"""Сгенерировать уникальный промокод для приза."""
# Генерируем уникальный код
code = f"{config.promo_prefix}{secrets.token_hex(4).upper()}"
# Определяем тип промокода
if prize.promo_subscription_days > 0:
promo_type = PromoCodeType.SUBSCRIPTION_DAYS.value
else:
promo_type = PromoCodeType.BALANCE.value
promocode = PromoCode(
code=code,
type=promo_type,
balance_bonus_kopeks=prize.promo_balance_bonus_kopeks,
subscription_days=prize.promo_subscription_days,
max_uses=1,
valid_until=datetime.utcnow() + timedelta(days=config.promo_validity_days),
is_active=True,
created_by=user.id,
)
db.add(promocode)
await db.flush()
return promocode
async def spin(
self,
db: AsyncSession,
user: User,
payment_type: str
) -> SpinResult:
"""
Выполнить спин колеса.
Шаги:
1. Проверить доступность
2. Обработать оплату
3. Рассчитать вероятности и выбрать приз
4. Применить приз
5. Создать запись WheelSpin
6. Вернуть результат
"""
try:
# 1. Проверяем доступность
availability = await self.check_availability(db, user)
if not availability.can_spin:
return SpinResult(
success=False,
error=availability.reason,
message=self._get_error_message(availability.reason),
)
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
return SpinResult(
success=False,
error="no_prizes",
message="Призы не настроены",
)
# 2. Обрабатываем оплату
if payment_type == WheelSpinPaymentType.TELEGRAM_STARS.value:
if not availability.can_pay_stars:
return SpinResult(
success=False,
error="cannot_pay_stars",
message="Оплата Stars недоступна",
)
payment_amount = config.spin_cost_stars
payment_value_kopeks = await self._process_stars_payment(db, user, config)
elif payment_type == WheelSpinPaymentType.SUBSCRIPTION_DAYS.value:
if not availability.can_pay_days:
return SpinResult(
success=False,
error="cannot_pay_days",
message="Оплата днями подписки недоступна",
)
payment_amount = config.spin_cost_days
payment_value_kopeks = await self._process_days_payment(db, user, config)
else:
return SpinResult(
success=False,
error="invalid_payment_type",
message="Неверный способ оплаты",
)
# 3. Рассчитываем вероятности и выбираем приз
prizes_with_probs = self.calculate_prize_probabilities(config, prizes, payment_value_kopeks)
selected_prize = self._select_prize(prizes_with_probs)
# 4. Рассчитываем угол для анимации
rotation = self._calculate_rotation(prizes, selected_prize)
# 5. Применяем приз
generated_promocode = await self._apply_prize(db, user, selected_prize, config)
promocode_id = None
if generated_promocode:
# Получаем ID промокода
result = await db.execute(
f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'"
)
row = result.fetchone()
if row:
promocode_id = row[0]
# 6. Создаем запись спина
spin = await create_wheel_spin(
db=db,
user_id=user.id,
prize_id=selected_prize.id,
payment_type=payment_type,
payment_amount=payment_amount,
payment_value_kopeks=payment_value_kopeks,
prize_type=selected_prize.prize_type,
prize_value=selected_prize.prize_value,
prize_display_name=selected_prize.display_name,
prize_value_kopeks=selected_prize.prize_value_kopeks,
generated_promocode_id=promocode_id,
is_applied=True,
)
await db.commit()
# 7. Формируем результат
message = self._get_prize_message(selected_prize, generated_promocode)
return SpinResult(
success=True,
prize_id=selected_prize.id,
prize_type=selected_prize.prize_type,
prize_value=selected_prize.prize_value,
prize_display_name=selected_prize.display_name,
emoji=selected_prize.emoji,
color=selected_prize.color,
rotation_degrees=rotation,
message=message,
promocode=generated_promocode,
)
except ValueError as e:
await db.rollback()
return SpinResult(
success=False,
error="payment_error",
message=str(e),
)
except Exception as e:
await db.rollback()
logger.exception(f"Ошибка спина колеса для user_id={user.id}: {e}")
return SpinResult(
success=False,
error="internal_error",
message="Произошла ошибка, попробуйте позже",
)
def _get_error_message(self, reason: Optional[str]) -> str:
"""Получить человекочитаемое сообщение об ошибке."""
messages = {
"wheel_disabled": "Колесо удачи временно недоступно",
"daily_limit_reached": "Вы достигли лимита спинов на сегодня",
"no_payment_method_available": "Нет доступных способов оплаты",
"no_prizes_configured": "Призы еще не настроены",
"insufficient_balance": "Недостаточно средств на балансе. Пополните баланс для оплаты спина.",
}
return messages.get(reason, "Произошла ошибка")
def _get_prize_message(self, prize: WheelPrize, promocode: Optional[str]) -> str:
"""Сформировать сообщение о выигрыше."""
prize_type = prize.prize_type
if prize_type == WheelPrizeType.NOTHING.value:
return "К сожалению, в этот раз не повезло. Попробуйте еще!"
if prize_type == WheelPrizeType.BALANCE_BONUS.value:
return f"Поздравляем! Вы выиграли {prize.prize_value/100:.0f}₽ на баланс!"
if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value:
days_word = self._pluralize_days(prize.prize_value)
return f"Поздравляем! Вы выиграли {prize.prize_value} {days_word} подписки!"
if prize_type == WheelPrizeType.TRAFFIC_GB.value:
return f"Поздравляем! Вы выиграли {prize.prize_value}GB трафика!"
if prize_type == WheelPrizeType.PROMOCODE.value:
return f"Поздравляем! Ваш промокод: {promocode}"
return "Поздравляем с выигрышем!"
def _pluralize_days(self, n: int) -> str:
"""Склонение слова 'день'."""
if 11 <= n % 100 <= 19:
return "дней"
elif n % 10 == 1:
return "день"
elif 2 <= n % 10 <= 4:
return "дня"
else:
return "дней"
async def get_statistics(
self,
db: AsyncSession,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> Dict[str, Any]:
"""Получить статистику колеса."""
return await get_wheel_statistics(db, date_from, date_to)
# Глобальный экземпляр сервиса
wheel_service = FortuneWheelService()
+9
View File
@@ -23,6 +23,11 @@ class SubscriptionStates(StatesGroup):
# Состояния для простой подписки
waiting_for_simple_subscription_payment_method = State()
# Состояния для кастомных дней/трафика при покупке тарифа
selecting_custom_days = State()
selecting_custom_traffic = State()
confirming_custom_purchase = State()
class BalanceStates(StatesGroup):
waiting_for_amount = State()
waiting_for_pal24_method = State()
@@ -177,9 +182,13 @@ class AdminStates(StatesGroup):
editing_tariff_tier = State()
editing_tariff_prices = State()
editing_tariff_device_price = State()
editing_tariff_max_devices = State()
editing_tariff_trial_days = State()
editing_tariff_squads = State()
editing_tariff_promo_groups = State()
editing_tariff_traffic_topup_packages = State()
editing_tariff_max_topup_traffic = State()
editing_tariff_daily_price = State()
class SupportStates(StatesGroup):
+13
View File
@@ -10,6 +10,7 @@ from .middleware import RequestLoggingMiddleware
from .routes import (
broadcasts,
backups,
ban_notifications,
campaigns,
config,
health,
@@ -165,6 +166,13 @@ OPENAPI_TAGS = [
"настройка показа при /start."
),
},
{
"name": "ban-notifications",
"description": (
"Эндпоинты для приема уведомлений от системы мониторинга ban (Banhammer). "
"Позволяет отправлять уведомления пользователям о блокировке и разблокировке."
),
},
]
@@ -258,6 +266,11 @@ def create_web_api_app() -> FastAPI:
)
app.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
app.include_router(websocket.router, tags=["websocket"])
app.include_router(
ban_notifications.router,
prefix="/ban-notifications",
tags=["ban-notifications"],
)
# Cabinet (Personal Account) routes
if settings.is_cabinet_enabled():
+144
View File
@@ -0,0 +1,144 @@
"""
API эндпоинты для приема уведомлений от ban системы
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.webapi.dependencies import get_db_session, require_api_token
from app.webapi.schemas.ban_notifications import (
BanNotificationRequest,
BanNotificationResponse,
)
from app.services.ban_notification_service import ban_notification_service
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/send",
response_model=BanNotificationResponse,
summary="Отправить уведомление от ban системы",
description=(
"Эндпоинт для отправки уведомлений пользователям от системы мониторинга ban. "
"Поддерживает уведомления о блокировке, разблокировке и предупреждения."
),
)
async def send_ban_notification(
request: BanNotificationRequest,
db: AsyncSession = Depends(get_db_session),
_token=Depends(require_api_token),
) -> BanNotificationResponse:
"""
Отправить уведомление пользователю от ban системы
- **punishment**: Уведомление о блокировке за превышение лимита устройств
- **enabled**: Уведомление о снятии блокировки
- **warning**: Предупреждение пользователю
Требует API ключ в заголовке X-API-Key или Authorization: Bearer <token>
"""
logger.info(
f"Получен запрос на отправку уведомления типа '{request.notification_type}' "
f"для пользователя {request.username} ({request.user_identifier}), "
f"node_name={request.node_name!r}"
)
try:
if request.notification_type == "punishment":
if request.ip_count is None or request.limit is None or request.ban_minutes is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Для типа 'punishment' требуются поля: ip_count, limit, ban_minutes"
)
success, message, telegram_id = await ban_notification_service.send_punishment_notification(
db=db,
user_identifier=request.user_identifier,
username=request.username,
ip_count=request.ip_count,
limit=request.limit,
ban_minutes=request.ban_minutes,
node_name=request.node_name,
)
elif request.notification_type == "enabled":
success, message, telegram_id = await ban_notification_service.send_enabled_notification(
db=db,
user_identifier=request.user_identifier,
username=request.username,
)
elif request.notification_type == "warning":
if not request.warning_message:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Для типа 'warning' требуется поле: warning_message"
)
success, message, telegram_id = await ban_notification_service.send_warning_notification(
db=db,
user_identifier=request.user_identifier,
username=request.username,
warning_message=request.warning_message,
)
elif request.notification_type == "network_wifi":
if request.ban_minutes is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Для типа 'network_wifi' требуется поле: ban_minutes"
)
success, message, telegram_id = await ban_notification_service.send_network_wifi_notification(
db=db,
user_identifier=request.user_identifier,
username=request.username,
ban_minutes=request.ban_minutes,
network_type=request.network_type,
node_name=request.node_name,
)
elif request.notification_type == "network_mobile":
if request.ban_minutes is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Для типа 'network_mobile' требуется поле: ban_minutes"
)
success, message, telegram_id = await ban_notification_service.send_network_mobile_notification(
db=db,
user_identifier=request.user_identifier,
username=request.username,
ban_minutes=request.ban_minutes,
network_type=request.network_type,
node_name=request.node_name,
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Неизвестный тип уведомления: {request.notification_type}"
)
return BanNotificationResponse(
success=success,
message=message,
telegram_id=telegram_id,
sent=success
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Ошибка при отправке уведомления: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Внутренняя ошибка сервера: {str(e)}"
)
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field
class BanNotificationRequest(BaseModel):
"""Запрос на отправку уведомления о бане пользователю"""
notification_type: Literal["punishment", "enabled", "warning", "network_wifi", "network_mobile"] = Field(
description="Тип уведомления: punishment (бан за устройства), enabled (разбан), warning (предупреждение), network_wifi (бан за WiFi), network_mobile (бан за мобильную сеть)"
)
user_identifier: str = Field(
description="Email или user_id пользователя из Remnawave Panel"
)
username: str = Field(
description="Имя пользователя для отображения"
)
# Данные для punishment
ip_count: Optional[int] = Field(None, description="Количество устройств")
limit: Optional[int] = Field(None, description="Лимит устройств")
ban_minutes: Optional[int] = Field(None, description="Длительность бана в минутах")
# Данные для warning
warning_message: Optional[str] = Field(None, description="Текст предупреждения")
# Данные для network_wifi/network_mobile и punishment
network_type: Optional[str] = Field(None, description="Тип сети (WiFi/Mobile)")
node_name: Optional[str] = Field(None, description="Название ноды/сервера с которой пришел бан")
class Config:
json_schema_extra = {
"example": {
"notification_type": "punishment",
"user_identifier": "user@example.com",
"username": "john_doe",
"ip_count": 5,
"limit": 3,
"ban_minutes": 30,
"node_name": "DE-Server-1"
}
}
class BanNotificationResponse(BaseModel):
"""Ответ на запрос отправки уведомления"""
success: bool = Field(description="Успешно ли отправлено уведомление")
message: str = Field(description="Сообщение о результате")
telegram_id: Optional[int] = Field(None, description="Telegram ID получателя")
sent: bool = Field(False, description="Было ли фактически отправлено сообщение")
class Config:
json_schema_extra = {
"example": {
"success": True,
"message": "Уведомление отправлено",
"telegram_id": 123456789,
"sent": True
}
}
+123 -2
View File
@@ -47,6 +47,13 @@ class MiniAppSubscriptionUser(BaseModel):
promo_offer_discount_percent: int = 0
promo_offer_discount_expires_at: Optional[datetime] = None
promo_offer_discount_source: Optional[str] = None
# Суточные тарифы
is_daily_tariff: bool = False
is_daily_paused: bool = False
daily_tariff_name: Optional[str] = None
daily_price_kopeks: Optional[int] = None
daily_price_label: Optional[str] = None
daily_next_charge_at: Optional[datetime] = None # Время следующего списания
class MiniAppPromoGroup(BaseModel):
@@ -55,7 +62,7 @@ class MiniAppPromoGroup(BaseModel):
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Dict[int, int] = Field(default_factory=dict)
period_discounts: Dict[str, int] = Field(default_factory=dict)
apply_discounts_to_addons: bool = True
@@ -70,7 +77,7 @@ class MiniAppAutoPromoGroupLevel(BaseModel):
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Dict[int, int] = Field(default_factory=dict)
period_discounts: Dict[str, int] = Field(default_factory=dict)
apply_discounts_to_addons: bool = True
@@ -503,6 +510,10 @@ class MiniAppTariffPeriod(BaseModel):
price_label: str
price_per_month_kopeks: Optional[int] = None
price_per_month_label: Optional[str] = None
# Скидка промогруппы
original_price_kopeks: Optional[int] = None # Цена без скидки
original_price_label: Optional[str] = None
discount_percent: int = 0 # Процент скидки
class MiniAppTariff(BaseModel):
@@ -520,6 +531,26 @@ class MiniAppTariff(BaseModel):
periods: List[MiniAppTariffPeriod] = Field(default_factory=list)
is_current: bool = False
is_available: bool = True
# Для режима мгновенного переключения тарифа
switch_cost_kopeks: Optional[int] = None # Стоимость переключения (None если не в режиме switch)
switch_cost_label: Optional[str] = None # Форматированная стоимость
is_upgrade: Optional[bool] = None # True = повышение, False = понижение
is_switch_free: Optional[bool] = None # True = бесплатное переключение
# Суточные тарифы
is_daily: bool = False
daily_price_kopeks: int = 0
daily_price_label: Optional[str] = None
class MiniAppTrafficTopupPackage(BaseModel):
"""Пакет докупки трафика."""
gb: int
price_kopeks: int
price_label: str
# Скидка промогруппы на трафик
original_price_kopeks: Optional[int] = None
original_price_label: Optional[str] = None
discount_percent: int = 0
class MiniAppCurrentTariff(BaseModel):
@@ -533,6 +564,34 @@ class MiniAppCurrentTariff(BaseModel):
is_unlimited_traffic: bool = False
device_limit: int
servers_count: int
# Месячная цена для расчёта стоимости переключения тарифа
monthly_price_kopeks: int = 0
# Докупка трафика
traffic_topup_enabled: bool = False
traffic_topup_packages: List[MiniAppTrafficTopupPackage] = Field(default_factory=list)
# Лимит докупки трафика (0 = без лимита)
max_topup_traffic_gb: int = 0
available_topup_gb: Optional[int] = None # Сколько еще можно докупить (None = без лимита)
# Суточные тарифы
is_daily: bool = False
daily_price_kopeks: int = 0
daily_price_label: Optional[str] = None
class MiniAppTrafficTopupRequest(BaseModel):
"""Запрос на докупку трафика."""
init_data: str = Field(..., alias="initData")
subscription_id: Optional[int] = Field(None, alias="subscriptionId")
gb: int
class MiniAppTrafficTopupResponse(BaseModel):
"""Ответ на докупку трафика."""
success: bool = True
message: str = ""
new_traffic_limit_gb: int = 0
new_balance_kopeks: int = 0
charged_kopeks: int = 0
class MiniAppTariffsRequest(BaseModel):
@@ -548,6 +607,7 @@ class MiniAppTariffsResponse(BaseModel):
current_tariff: Optional[MiniAppCurrentTariff] = None
balance_kopeks: int = 0
balance_label: Optional[str] = None
promo_group: Optional[MiniAppPromoGroup] = None # Промогруппа пользователя для отображения скидок
class MiniAppTariffPurchaseRequest(BaseModel):
@@ -569,11 +629,72 @@ class MiniAppTariffPurchaseResponse(BaseModel):
balance_label: Optional[str] = None
class MiniAppTariffSwitchRequest(BaseModel):
"""Запрос на переключение тарифа (без выбора периода)."""
init_data: str = Field(...)
tariff_id: int = Field(...)
class MiniAppTariffSwitchPreviewResponse(BaseModel):
"""Предпросмотр переключения тарифа."""
can_switch: bool = True
current_tariff_id: Optional[int] = None
current_tariff_name: Optional[str] = None
new_tariff_id: int
new_tariff_name: str
remaining_days: int = 0
upgrade_cost_kopeks: int = 0 # 0 если даунгрейд или равная цена
upgrade_cost_label: str = ""
balance_kopeks: int = 0
balance_label: str = ""
has_enough_balance: bool = True
missing_amount_kopeks: int = 0
missing_amount_label: str = ""
is_upgrade: bool = False # True если новый тариф дороже
message: Optional[str] = None
class MiniAppTariffSwitchResponse(BaseModel):
"""Ответ на переключение тарифа."""
success: bool = True
message: Optional[str] = None
tariff_id: int
tariff_name: str
charged_kopeks: int = 0
balance_kopeks: int = 0
balance_label: str = ""
class MiniAppDailySubscriptionToggleRequest(BaseModel):
"""Запрос на паузу/возобновление суточной подписки."""
init_data: str = Field(...)
class MiniAppDailySubscriptionToggleResponse(BaseModel):
"""Ответ на паузу/возобновление суточной подписки."""
success: bool = True
message: Optional[str] = None
is_paused: bool = False
balance_kopeks: int = 0
balance_label: str = ""
class MiniAppTrafficPurchase(BaseModel):
"""Докупка трафика с индивидуальной датой истечения."""
id: int
traffic_gb: int
expires_at: datetime
created_at: datetime
days_remaining: int
progress_percent: float
class MiniAppSubscriptionResponse(BaseModel):
success: bool = True
subscription_id: Optional[int] = None
remnawave_short_uuid: Optional[str] = None
user: MiniAppSubscriptionUser
traffic_purchases: List[MiniAppTrafficPurchase] = Field(default_factory=list)
subscription_url: Optional[str] = None
subscription_crypto_link: Optional[str] = None
subscription_purchase_url: Optional[str] = None
+95
View File
@@ -798,6 +798,100 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
if settings.is_freekassa_enabled():
@router.options(settings.FREEKASSA_WEBHOOK_PATH)
async def freekassa_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
)
@router.get(settings.FREEKASSA_WEBHOOK_PATH)
async def freekassa_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "freekassa_webhook",
"enabled": settings.is_freekassa_enabled(),
}
)
@router.post(settings.FREEKASSA_WEBHOOK_PATH)
async def freekassa_webhook(request: Request) -> Response:
# Получаем IP клиента с учетом прокси
x_forwarded_for = request.headers.get("X-Forwarded-For")
if x_forwarded_for:
client_ip = x_forwarded_for.split(",")[0].strip()
else:
real_ip = request.headers.get("X-Real-IP")
if real_ip:
client_ip = real_ip.strip()
else:
client_ip = request.client.host if request.client else "127.0.0.1"
# Получаем данные формы
try:
form_data = await request.form()
except Exception:
logger.error("Freekassa webhook: не удалось прочитать данные формы")
return Response("Error reading form data", status_code=status.HTTP_400_BAD_REQUEST)
# Извлекаем параметры
merchant_id = form_data.get("MERCHANT_ID")
amount = form_data.get("AMOUNT")
order_id = form_data.get("MERCHANT_ORDER_ID")
sign = form_data.get("SIGN")
intid = form_data.get("intid")
cur_id = form_data.get("CUR_ID")
if not all([merchant_id, amount, order_id, sign, intid]):
logger.warning("Freekassa webhook: отсутствуют обязательные параметры")
return Response("Missing parameters", status_code=status.HTTP_400_BAD_REQUEST)
# Преобразуем типы
try:
merchant_id_int = int(merchant_id)
amount_float = float(amount)
cur_id_int = int(cur_id) if cur_id else None
except ValueError:
logger.warning("Freekassa webhook: неверный формат параметров")
return Response("Invalid parameters format", status_code=status.HTTP_400_BAD_REQUEST)
# Обрабатываем callback
db_generator = get_db()
try:
db = await db_generator.__anext__()
except StopAsyncIteration:
return Response("DB Error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
try:
success = await payment_service.process_freekassa_webhook(
db,
merchant_id=merchant_id_int,
amount=amount_float,
order_id=order_id,
sign=sign,
intid=intid,
cur_id=cur_id_int,
client_ip=client_ip,
)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
if success:
return Response("YES", status_code=status.HTTP_200_OK)
return Response("Error", status_code=status.HTTP_400_BAD_REQUEST)
routes_registered = True
if routes_registered:
@router.get("/health/payment-webhooks")
async def payment_webhooks_health() -> JSONResponse:
@@ -813,6 +907,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"pal24_enabled": settings.is_pal24_enabled(),
"platega_enabled": settings.is_platega_enabled(),
"cloudpayments_enabled": settings.is_cloudpayments_enabled(),
"freekassa_enabled": settings.is_freekassa_enabled(),
}
)
+1
View File
@@ -120,6 +120,7 @@ def create_unified_app(
"pal24": settings.is_pal24_enabled(),
"wata": settings.is_wata_enabled(),
"heleket": settings.is_heleket_enabled(),
"freekassa": settings.is_freekassa_enabled(),
}
if enable_telegram_webhook:
+25 -15
View File
@@ -7,14 +7,18 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C'
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}',
]
interval: 30s
timeout: 5s
retries: 5
@@ -31,7 +35,7 @@ services:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ['CMD', 'redis-cli', 'ping']
interval: 30s
timeout: 10s
retries: 3
@@ -48,16 +52,16 @@ services:
env_file:
- .env
environment:
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
DOCKER_ENV: 'true'
DATABASE_MODE: 'auto'
POSTGRES_HOST: 'postgres'
POSTGRES_PORT: '5432'
POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}'
POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}'
REDIS_URL: 'redis://redis:6379/0'
TZ: 'Europe/Moscow'
LOCALES_PATH: '${LOCALES_PATH:-/app/locales}'
volumes:
- ./logs:/app/logs:rw
- ./data:/app/data:rw
@@ -66,12 +70,16 @@ services:
- /etc/localtime:/etc/localtime:ro
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- '${WEB_API_PORT:-8080}:8080'
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
test:
[
'CMD-SHELL',
'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"',
]
interval: 60s
timeout: 10s
retries: 3
@@ -90,6 +98,8 @@ networks:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
driver_opts:
com.docker.network.driver.mtu: 1350
remnawave-network:
name: remnawave-network
+25 -15
View File
@@ -7,13 +7,17 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C'
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}',
]
interval: 30s
timeout: 5s
retries: 5
@@ -29,7 +33,7 @@ services:
networks:
- bot_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ['CMD', 'redis-cli', 'ping']
interval: 30s
timeout: 10s
retries: 3
@@ -46,18 +50,18 @@ services:
env_file:
- .env
environment:
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
DOCKER_ENV: 'true'
DATABASE_MODE: 'auto'
POSTGRES_HOST: 'postgres'
POSTGRES_PORT: '5432'
POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}'
POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}'
REDIS_URL: "redis://redis:6379/0"
REDIS_URL: 'redis://redis:6379/0'
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
TZ: 'Europe/Moscow'
LOCALES_PATH: '${LOCALES_PATH:-/app/locales}'
volumes:
# Логи
- ./logs:/app/logs:rw
@@ -72,11 +76,15 @@ services:
# Логотип для сообщений
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- '${WEB_API_PORT:-8080}:8080'
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
test:
[
'CMD-SHELL',
'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"',
]
interval: 60s
timeout: 10s
retries: 3
@@ -95,3 +103,5 @@ networks:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
driver_opts:
com.docker.network.driver.mtu: 1350
+70
View File
@@ -36,11 +36,13 @@ from app.services.referral_contest_service import referral_contest_service
from app.services.contest_rotation_service import contest_rotation_service
from app.services.nalogo_queue_service import nalogo_queue_service
from app.services.traffic_monitoring_service import traffic_monitoring_scheduler
from app.services.daily_subscription_service import daily_subscription_service
from app.utils.startup_timeline import StartupTimeline
from app.utils.timezone import TimezoneAwareFormatter
from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter
from app.utils.payment_logger import payment_logger, configure_payment_logger
from app.services.log_rotation_service import log_rotation_service
from app.services.ban_notification_service import ban_notification_service
class GracefulExit:
@@ -174,6 +176,7 @@ async def main():
maintenance_task = None
version_check_task = None
traffic_monitoring_task = None
daily_subscription_task = None
polling_task = None
web_api_server = None
telegram_webhook_enabled = False
@@ -219,6 +222,34 @@ async def main():
"SKIP_MIGRATION=true",
)
async with timeline.stage(
"Синхронизация тарифов из конфига",
"💰",
success_message="Тарифы синхронизированы",
) as stage:
try:
from app.database.crud.tariff import ensure_tariffs_synced
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await ensure_tariffs_synced(db)
except Exception as error:
stage.warning(f"Не удалось синхронизировать тарифы: {error}")
logger.error(f"❌ Не удалось синхронизировать тарифы: {error}")
async with timeline.stage(
"Синхронизация серверов из RemnaWave",
"🖥️",
success_message="Серверы синхронизированы",
) as stage:
try:
from app.database.crud.server_squad import ensure_servers_synced
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await ensure_servers_synced(db)
except Exception as error:
stage.warning(f"Не удалось синхронизировать серверы: {error}")
logger.error(f"❌ Не удалось синхронизировать серверы: {error}")
async with timeline.stage(
"Загрузка конфигурации из БД",
"⚙️",
@@ -239,7 +270,9 @@ async def main():
monitoring_service.bot = bot
maintenance_service.set_bot(bot)
broadcast_service.set_bot(bot)
ban_notification_service.set_bot(bot)
traffic_monitoring_scheduler.set_bot(bot)
daily_subscription_service.set_bot(bot)
from app.services.admin_notification_service import AdminNotificationService
@@ -596,6 +629,21 @@ async def main():
traffic_monitoring_task = None
stage.skip("Мониторинг трафика отключен настройками")
async with timeline.stage(
"Суточные подписки",
"💳",
success_message="Сервис суточных подписок запущен",
) as stage:
if daily_subscription_service.is_enabled():
daily_subscription_task = asyncio.create_task(
daily_subscription_service.start_monitoring()
)
interval_minutes = daily_subscription_service.get_check_interval_minutes()
stage.log(f"Интервал проверки: {interval_minutes} мин")
else:
daily_subscription_task = None
stage.skip("Суточные подписки отключены настройками")
async with timeline.stage(
"Сервис проверки версий",
"📄",
@@ -647,6 +695,8 @@ async def main():
webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}")
if settings.is_heleket_enabled():
webhook_lines.append(f"Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}")
if settings.is_freekassa_enabled():
webhook_lines.append(f"Freekassa: {_fmt(settings.FREEKASSA_WEBHOOK_PATH)}")
timeline.log_section(
"Активные webhook endpoints",
@@ -658,6 +708,7 @@ async def main():
f"Мониторинг: {'Включен' if monitoring_task else 'Отключен'}",
f"Техработы: {'Включен' if maintenance_task else 'Отключен'}",
f"Мониторинг трафика: {'Включен' if traffic_monitoring_task else 'Отключен'}",
f"Суточные подписки: {'Включен' if daily_subscription_task else 'Отключен'}",
f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}",
f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}",
]
@@ -712,6 +763,16 @@ async def main():
traffic_monitoring_scheduler.start_monitoring()
)
if daily_subscription_task and daily_subscription_task.done():
exception = daily_subscription_task.exception()
if exception:
logger.error(f"Сервис суточных подписок завершился с ошибкой: {exception}")
if daily_subscription_service.is_enabled():
logger.info("🔄 Перезапуск сервиса суточных подписок...")
daily_subscription_task = asyncio.create_task(
daily_subscription_service.start_monitoring()
)
if auto_verification_active and not auto_payment_verification_service.is_running():
logger.warning(
"Сервис автопроверки пополнений остановился, пробуем перезапустить..."
@@ -781,6 +842,15 @@ async def main():
except asyncio.CancelledError:
pass
if daily_subscription_task and not daily_subscription_task.done():
logger.info("ℹ️ Остановка сервиса суточных подписок...")
daily_subscription_service.stop_monitoring()
daily_subscription_task.cancel()
try:
await daily_subscription_task
except asyncio.CancelledError:
pass
logger.info("ℹ️ Остановка сервиса отчетов...")
try:
await reporting_service.stop()
+1931 -58
View File
File diff suppressed because it is too large Load Diff