Merge branch 'main' into main
This commit is contained in:
+11
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
**🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API**
|
||||
|
||||
*Полнофункциональное решение с управлением пользователями, платежами и администрированием*
|
||||
_Полнофункциональное решение с управлением пользователями, платежами и администрированием_
|
||||
|
||||
[](https://python.org)
|
||||
[](https://postgresql.org)
|
||||
@@ -34,14 +34,16 @@
|
||||
Вы хотите спать — он позволит и это...
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA + Freekassa (NSPK СБП + карты) + CloudPayments (карты + СБП)
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
|
||||
|
||||
|
||||
### 🎛️ **Гибкость конфигурации**
|
||||
|
||||
- 🌐 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор
|
||||
- 📱 **Управление устройствами** - от 1 до неограниченного количества
|
||||
- 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов
|
||||
@@ -51,6 +53,7 @@
|
||||
- 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса
|
||||
|
||||
### 💪 **Enterprise готовность**
|
||||
|
||||
- 🗃️ **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура
|
||||
- 🔒 **Безопасность** - интеграция с системой защиты панели через куки-аутентификацию
|
||||
- 📈 **Масштабируемость** - от стартапа до крупного бизнеса
|
||||
@@ -66,12 +69,12 @@
|
||||
|
||||
### 📚 Поддерживаемые методы авторизации
|
||||
|
||||
| Конфигурация | 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> |
|
||||
| Cookies (eGames) | Bearer <api_key> | <api_key> + cookies |
|
||||
| Конфигурация | 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> |
|
||||
| Cookies (eGames) | Bearer <api_key> | <api_key> + cookies |
|
||||
|
||||
---
|
||||
|
||||
@@ -135,11 +138,11 @@ docker compose logs
|
||||
|
||||
### 1. Выбор режима запуска
|
||||
|
||||
| `BOT_RUN_MODE` | Что делает | Когда использовать |
|
||||
|----------------|------------|---------------------|
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||
| `BOT_RUN_MODE` | Что делает | Когда использовать |
|
||||
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||
|
||||
### 2. Минимальные настройки для 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 # Подключаем к сети панели
|
||||
- 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
|
||||
@@ -363,9 +384,9 @@ services:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- bot_network
|
||||
- remnawave-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 # Подключаем к сети панели
|
||||
- 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,13 +449,16 @@ 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
|
||||
external: true # Используем существующую сеть панели
|
||||
external: true # Используем существующую сеть панели
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
|
||||
- Сеть `remnawave-network` должна быть создана панелью Remnawave
|
||||
- В `.env` укажите `REMNAWAVE_API_URL=http://remnawave:3000` (обращение внутри Docker-сети)
|
||||
- Бот сможет напрямую общаться с панелью без внешних запросов
|
||||
@@ -460,10 +488,10 @@ curl -s https://bot.example.com/health/unified | jq
|
||||
`Caddyfile`:
|
||||
|
||||
```caddy
|
||||
# Hooks + API
|
||||
# Hooks + API
|
||||
hooks.domain.com {
|
||||
encode gzip zstd
|
||||
|
||||
|
||||
# Webhook пути для платежных систем
|
||||
handle /yookassa-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
@@ -474,7 +502,7 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handle /platega-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
@@ -484,7 +512,7 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handle /cryptobot-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
@@ -494,7 +522,7 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handle /wata-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
@@ -504,7 +532,7 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handle /heleket-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
@@ -514,7 +542,7 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handle /tribute-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
@@ -524,7 +552,7 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handle /pal24-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
@@ -534,7 +562,7 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handle /mulenpay-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
@@ -576,7 +604,7 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Все остальные запросы
|
||||
handle {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
@@ -592,7 +620,7 @@ hooks.domain.com {
|
||||
# Статика для miniapp
|
||||
miniapp.domain.com {
|
||||
encode gzip zstd
|
||||
|
||||
|
||||
# API эндпоинты /miniapp/* в приложение
|
||||
handle /miniapp/* {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
@@ -603,7 +631,7 @@ miniapp.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# app-config.json с CORS
|
||||
handle /app-config.json {
|
||||
header Access-Control-Allow-Origin "*"
|
||||
@@ -615,7 +643,7 @@ miniapp.domain.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Статические файлы (корень и всё остальное)
|
||||
handle {
|
||||
root * /var/www/remnawave-miniapp
|
||||
@@ -636,22 +664,22 @@ events {}
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
sendfile on;
|
||||
|
||||
|
||||
upstream remnawave_bot_unified {
|
||||
server remnawave_bot:8080;
|
||||
}
|
||||
|
||||
|
||||
# Hooks + API домен
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name hooks.domain.com;
|
||||
|
||||
|
||||
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
|
||||
|
||||
|
||||
client_max_body_size 32m;
|
||||
|
||||
|
||||
# Webhook пути для платежных систем
|
||||
location = /yookassa-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
@@ -664,7 +692,7 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
location = /platega-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
@@ -676,7 +704,7 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
location = /cryptobot-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
@@ -688,7 +716,7 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
location = /wata-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
@@ -700,7 +728,7 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
location = /heleket-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
@@ -712,7 +740,7 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
location = /tribute-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
@@ -724,7 +752,7 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
location = /pal24-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
@@ -736,7 +764,7 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
location = /mulenpay-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
@@ -782,7 +810,7 @@ http {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
|
||||
# Всё остальное
|
||||
location / {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
@@ -796,18 +824,18 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Miniapp домен (статика + API)
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name miniapp.domain.com;
|
||||
|
||||
|
||||
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
|
||||
|
||||
|
||||
client_max_body_size 32m;
|
||||
|
||||
|
||||
# API эндпоинты /miniapp/*
|
||||
location /miniapp/ {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
@@ -820,7 +848,7 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
# app-config.json с CORS
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
@@ -830,12 +858,12 @@ http {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
|
||||
# Статические файлы
|
||||
location / {
|
||||
root /var/www/remnawave-miniapp;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
|
||||
# Кэширование
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, immutable";
|
||||
@@ -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 вашего сервера
|
||||
@@ -954,11 +990,11 @@ 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_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
|
||||
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
@@ -984,11 +1020,11 @@ 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` |
|
||||
| Переменная | Описание | Пример |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------ | --------------------------------- |
|
||||
| `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` |
|
||||
|
||||
### 🛡️ Защита панели Remnawave
|
||||
|
||||
@@ -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-ключами и токенами
|
||||
- 📊 Эндпоинты для подписок, пользователей, транзакций
|
||||
@@ -1465,12 +1528,12 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
|
||||
## 🚀 Производительность
|
||||
|
||||
| Пользователей | Память | CPU | Диск | Описание |
|
||||
|---------------|--------|-----|------|----------|
|
||||
| **1,000** | 512MB | 1 vCPU | 10GB | ✅ Стартап |
|
||||
| **10,000** | 2GB | 2 vCPU | 50GB | ✅ Малый бизнес |
|
||||
| **50,000** | 4GB | 4 vCPU | 100GB | ✅ Средний бизнес |
|
||||
| **100,000+** | 8GB+ | 8+ vCPU | 200GB+ | 🚀 Enterprise |
|
||||
| Пользователей | Память | CPU | Диск | Описание |
|
||||
| ------------- | ------ | ------- | ------ | ----------------- |
|
||||
| **1,000** | 512MB | 1 vCPU | 10GB | ✅ Стартап |
|
||||
| **10,000** | 2GB | 2 vCPU | 50GB | ✅ Малый бизнес |
|
||||
| **50,000** | 4GB | 4 vCPU | 100GB | ✅ Средний бизнес |
|
||||
| **100,000+** | 8GB+ | 8+ vCPU | 200GB+ | 🚀 Enterprise |
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
@@ -1588,14 +1656,14 @@ docker system prune
|
||||
|
||||
### 🚨 Частые проблемы и решения
|
||||
|
||||
| Проблема | Диагностика | Решение |
|
||||
|----------|-------------|---------|
|
||||
| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет |
|
||||
| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL |
|
||||
| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет |
|
||||
| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` |
|
||||
| **Корзина не сохраняется** | `docker compose ps redis` | Проверь статус Redis |
|
||||
| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах |
|
||||
| Проблема | Диагностика | Решение |
|
||||
| -------------------------- | ---------------------------------------------------- | -------------------------------------- |
|
||||
| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет |
|
||||
| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL |
|
||||
| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет |
|
||||
| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` |
|
||||
| **Корзина не сохраняется** | `docker compose ps redis` | Проверь статус Redis |
|
||||
| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах |
|
||||
|
||||
---
|
||||
|
||||
@@ -1639,14 +1707,14 @@ docker system prune
|
||||
# Для защищенных панелей
|
||||
REMNAWAVE_SECRET_KEY=secret_name:secret_value
|
||||
|
||||
# Для панелей eGames скрипта
|
||||
# Для панелей eGames скрипта
|
||||
REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
```
|
||||
|
||||
### 🔒 Дополнительные меры безопасности
|
||||
|
||||
- **Валидация всех входящих данных**
|
||||
- **Rate limiting для защиты от спама**
|
||||
- **Rate limiting для защиты от спама**
|
||||
- **Шифрование чувствительных данных**
|
||||
- **Автоматическое управление сессиями**
|
||||
- **Мониторинг подозрительной активности**
|
||||
@@ -1677,7 +1745,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
### 📚 **Полезные ресурсы**
|
||||
|
||||
- **📖 [https://docs.remna.st](https://docs.rw)))** - документация панели
|
||||
- **🤖 [Telegram Bot API](https://core.telegram.org/bots/api)** - API ботов
|
||||
- **🤖 [Telegram Bot API](https://core.telegram.org/bots/api)** - API ботов
|
||||
- **🐳 [Docker Guide](https://docs.docker.com/get-started/)** - обучение Docker
|
||||
- **🛡️ [Reverse Proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)** - защита панели
|
||||
|
||||
@@ -1764,16 +1832,16 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
|
||||
## 🎯 Ключевые особенности в цифрах
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| 💳 **Платёжных систем** | 11 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA, Freekassa, CloudPayments) |
|
||||
| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения |
|
||||
| 📊 **Периодов подписки** | 6 (от 14 дней до года) |
|
||||
| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) |
|
||||
| 🔌 **REST API эндпоинтов** | 50+ для полного управления |
|
||||
| 📱 **Режимов работы** | 3 (классический + тарифы + MiniApp focus) |
|
||||
| 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) |
|
||||
| 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором |
|
||||
| Метрика | Значение |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| 💳 **Платёжных систем** | 11 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA, Freekassa, CloudPayments) |
|
||||
| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения |
|
||||
| 📊 **Периодов подписки** | 6 (от 14 дней до года) |
|
||||
| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) |
|
||||
| 🔌 **REST API эндпоинтов** | 50+ для полного управления |
|
||||
| 📱 **Режимов работы** | 3 (классический + тарифы + MiniApp focus) |
|
||||
| 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) |
|
||||
| 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором |
|
||||
|
||||
---
|
||||
|
||||
@@ -1786,7 +1854,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
✅ **Прозрачная аналитика** - всегда знаете, сколько зарабатываете
|
||||
✅ **Гибкие тарифы** - настройте цены под свою аудиторию
|
||||
✅ **Система лояльности** - удерживайте клиентов промо-группами и скидками
|
||||
✅ **Масштабируемость** - от 10 до 100,000+ пользователей
|
||||
✅ **Масштабируемость** - от 10 до 100,000+ пользователей
|
||||
|
||||
### 🛠️ **Для разработчиков**
|
||||
|
||||
@@ -1795,7 +1863,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
✅ **Полное API** - интегрируйте с любыми сервисами
|
||||
✅ **Docker-ready** - разворачивается за минуты
|
||||
✅ **Подробная документация** - все описано и понятно
|
||||
✅ **Активное сообщество** - помощь в Telegram чате
|
||||
✅ **Активное сообщество** - помощь в Telegram чате
|
||||
|
||||
### 👥 **Для пользователей**
|
||||
|
||||
@@ -1804,7 +1872,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
✅ **Быстрая поддержка** - система тикетов с приоритетами
|
||||
✅ **Прозрачность** - всегда видите, за что платите
|
||||
✅ **Бонусы и скидки** - реферальная программа и промо-акции
|
||||
✅ **Удобное управление** - все в одном месте, в Telegram
|
||||
✅ **Удобное управление** - все в одном месте, в Telegram
|
||||
|
||||
---
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)}",
|
||||
)
|
||||
|
||||
@@ -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)}",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
+1923
-47
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
+239
-38
@@ -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 = []
|
||||
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]
|
||||
|
||||
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:
|
||||
allowed_periods.add(int(period_str))
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
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]
|
||||
|
||||
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:
|
||||
# Базовое описание
|
||||
@@ -2097,13 +2258,27 @@ class Settings(BaseSettings):
|
||||
def get_support_system_mode(self) -> str:
|
||||
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
|
||||
def is_support_tickets_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"tickets", "both"}
|
||||
|
||||
|
||||
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,17 +2401,43 @@ _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()
|
||||
PERIOD_PRICES.update(
|
||||
{
|
||||
days: getattr(settings, field_name, 0)
|
||||
for days, field_name in _PERIOD_PRICE_FIELDS.items()
|
||||
}
|
||||
)
|
||||
|
||||
if _DB_PERIOD_PRICES:
|
||||
# Используем цены из БД
|
||||
PERIOD_PRICES.update(_DB_PERIOD_PRICES)
|
||||
else:
|
||||
# Fallback на .env
|
||||
PERIOD_PRICES.update(
|
||||
{
|
||||
days: getattr(settings, field_name, 0)
|
||||
for days, field_name in _PERIOD_PRICE_FIELDS.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
PERIOD_PRICES: Dict[int, int] = {}
|
||||
|
||||
@@ -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,38 +530,158 @@ async def get_random_trial_squad_uuid(
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
"""Генерирует отображаемое название сервера на основе оригинального имени."""
|
||||
|
||||
country_names = {
|
||||
# Европа
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'US': '🇺🇸 США',
|
||||
'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': '🇰🇪 Кения',
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
return f"🌍 {original_name}"
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> Optional[str]:
|
||||
|
||||
codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU']
|
||||
"""Извлекает код страны из оригинального названия."""
|
||||
|
||||
# Полный список кодов стран
|
||||
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',
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -24,10 +24,13 @@ 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)
|
||||
.limit(1)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
@@ -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,24 +320,22 @@ 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 subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
|
||||
# Вычисляем остаток триала
|
||||
if subscription.end_date and subscription.end_date > current_time:
|
||||
remaining = subscription.end_date - current_time
|
||||
if remaining.total_seconds() > 0:
|
||||
bonus_days = max(0, remaining.days)
|
||||
logger.info(
|
||||
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
|
||||
bonus_days,
|
||||
subscription.id,
|
||||
)
|
||||
|
||||
# Применяем продление с учетом бонусных дней
|
||||
total_days = days + bonus_days
|
||||
|
||||
if days < 0:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
@@ -342,12 +344,35 @@ async def extend_subscription(
|
||||
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:
|
||||
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:
|
||||
bonus_days = max(0, remaining.days)
|
||||
logger.info(
|
||||
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
|
||||
bonus_days,
|
||||
subscription.id,
|
||||
)
|
||||
total_days = days + bonus_days
|
||||
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
|
||||
subscription.purchased_traffic_gb = 0
|
||||
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
# В режиме тарифов сохраняем докупленный трафик при продлении
|
||||
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
|
||||
@@ -434,14 +489,50 @@ async def add_subscription_traffic(
|
||||
subscription: Subscription,
|
||||
gb: int
|
||||
) -> Subscription:
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1443,9 +1534,9 @@ async def check_and_update_subscription_status(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Subscription:
|
||||
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
|
||||
logger.info(
|
||||
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
|
||||
subscription.id,
|
||||
@@ -1453,20 +1544,29 @@ async def check_and_update_subscription_status(
|
||||
format_local_datetime(subscription.end_date),
|
||||
format_local_datetime(current_time),
|
||||
)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
|
||||
# Для суточных тарифов с паузой не меняем статус на 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):
|
||||
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
|
||||
elif subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений")
|
||||
|
||||
|
||||
return subscription
|
||||
|
||||
async def create_subscription_no_commit(
|
||||
@@ -1736,7 +1836,239 @@ async def activate_pending_subscription(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(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)
|
||||
|
||||
@@ -52,50 +52,62 @@ 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()
|
||||
|
||||
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
|
||||
users_with_paid = users_with_paid_result.scalar() or 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
|
||||
|
||||
|
||||
avg_trial_duration_result = await db.execute(
|
||||
select(func.avg(SubscriptionConversion.trial_duration_days))
|
||||
)
|
||||
avg_trial_duration = avg_trial_duration_result.scalar() or 0
|
||||
|
||||
|
||||
avg_first_payment_result = await db.execute(
|
||||
select(func.avg(SubscriptionConversion.first_payment_amount_kopeks))
|
||||
)
|
||||
avg_first_payment = avg_first_payment_result.scalar() or 0
|
||||
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_conversions_result = await db.execute(
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+295
-3
@@ -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:
|
||||
@@ -1093,10 +1213,55 @@ class Subscription(Base):
|
||||
self.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
def add_traffic(self, gb: int):
|
||||
if self.traffic_limit_gb == 0:
|
||||
if self.traffic_limit_gb == 0:
|
||||
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"
|
||||
@@ -2249,4 +2414,131 @@ 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}>"
|
||||
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}'>"
|
||||
|
||||
@@ -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,9 +5426,682 @@ 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("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
|
||||
try:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Тип базы данных: {db_type}")
|
||||
@@ -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"))
|
||||
|
||||
Vendored
+6
-2
@@ -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__)
|
||||
|
||||
|
||||
|
||||
+359
-27
@@ -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,10 +203,11 @@ 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"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -481,7 +519,63 @@ async def show_broadcast_targets(
|
||||
"🎯 <b>Выбор целевой аудитории</b>\n\n"
|
||||
"Выберите категорию пользователей для рассылки:",
|
||||
reply_markup=get_broadcast_target_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -654,23 +748,34 @@ async def select_broadcast_target(
|
||||
"active_zero": "Активная подписка, трафик 0 ГБ",
|
||||
"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>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
await callback.answer()
|
||||
|
||||
@@ -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:
|
||||
# Удаляем текущее сообщение и отправляем новое с фото
|
||||
await callback.message.delete()
|
||||
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,13 +1248,14 @@ 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,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
if target.startswith('custom_'):
|
||||
users = await get_custom_users(db, target.replace('custom_', ''))
|
||||
else:
|
||||
@@ -1284,19 +1398,221 @@ async def confirm_broadcast(
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
|
||||
]),
|
||||
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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
+394
-2
@@ -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
|
||||
@@ -5673,7 +6044,28 @@ def register_handlers(dp: Dispatcher):
|
||||
admin_buy_subscription_execute,
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
base_price_original = PERIOD_PRICES.get(duration_days, 0)
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,11 +69,11 @@ 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
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
@@ -140,11 +140,11 @@ 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
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
+22
-4
@@ -11,13 +11,25 @@ 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
|
||||
):
|
||||
|
||||
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"UNKNOWN_CALLBACK_ALERT",
|
||||
@@ -25,7 +37,7 @@ async def handle_unknown_callback(
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
@@ -99,7 +111,13 @@ 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
@@ -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,12 +1189,39 @@ 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", "Выберите действие:")
|
||||
|
||||
info_sections: list[str] = []
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 != "Генерируется..."
|
||||
|
||||
@@ -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,31 +335,116 @@ 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
|
||||
|
||||
message_template = texts.t(
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
|
||||
"""👤 {full_name}
|
||||
# Формируем блок информации о тарифе
|
||||
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}
|
||||
📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}
|
||||
|
||||
📱 Информация о подписке
|
||||
🎭 Тип: {subscription_type}{tariff_line}
|
||||
🎭 Тип: {subscription_type}
|
||||
📈 Трафик: {traffic}
|
||||
🌍 Серверы: {servers}
|
||||
📱 Устройства: {devices_used} / {device_limit}""",
|
||||
)
|
||||
else:
|
||||
message_template = texts.t(
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
|
||||
"""👤 {full_name}
|
||||
💰 Баланс: {balance}
|
||||
📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}
|
||||
|
||||
📱 Информация о подписке
|
||||
🎭 Тип: {subscription_type}
|
||||
📅 Действует до: {end_date}
|
||||
⏰ Осталось: {time_left}
|
||||
📈 Трафик: {traffic}
|
||||
🌍 Серверы: {servers}
|
||||
📱 Устройства: {devices_used} / {device_limit}""",
|
||||
)
|
||||
)
|
||||
|
||||
if not show_devices:
|
||||
message_template = message_template.replace(
|
||||
@@ -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
@@ -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,15 +508,30 @@ 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 = settings.get_traffic_topup_price(traffic_gb)
|
||||
# Получаем цену: из тарифа или из глобальных настроек
|
||||
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:
|
||||
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
# Клавиатура с быстрыми действиями для админов в топике
|
||||
|
||||
@@ -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")]
|
||||
])
|
||||
|
||||
|
||||
+137
-36
@@ -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:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend")
|
||||
])
|
||||
keyboard.append([
|
||||
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([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки подписки"),
|
||||
callback_data="subscription_settings",
|
||||
)
|
||||
])
|
||||
# Кнопка смены тарифа для режима тарифов
|
||||
if settings.is_tariffs_mode() and subscription:
|
||||
# Проверяем, является ли тариф суточным
|
||||
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("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"),
|
||||
callback_data="tariff_switch"
|
||||
text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"),
|
||||
callback_data="subscription_autopay",
|
||||
)
|
||||
])
|
||||
|
||||
# Ряд: [Настройки] [Тариф] (если режим тарифов)
|
||||
settings_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"),
|
||||
callback_data="subscription_settings",
|
||||
)
|
||||
]
|
||||
if settings.is_tariffs_mode() and subscription:
|
||||
# Для суточных тарифов переходим на список тарифов, для обычных - мгновенное переключение
|
||||
tariff_callback = "tariff_switch" if is_daily_tariff else "instant_switch"
|
||||
settings_row.append(
|
||||
InlineKeyboardButton(
|
||||
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", "📈 Докупить трафик"),
|
||||
@@ -1795,7 +1819,81 @@ 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")
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
|
||||
@@ -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": "▶️ Подписка возобновлена!"
|
||||
}
|
||||
|
||||
@@ -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": "⏳ Очікує активації",
|
||||
|
||||
@@ -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":"⏳等待激活",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,52 +2,46 @@ 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,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
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:
|
||||
# Используем db и user из AuthMiddleware - не создаём новую сессию!
|
||||
db = data.get('db')
|
||||
user = data.get('db_user')
|
||||
|
||||
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 <= current_time):
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)")
|
||||
break
|
||||
|
||||
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
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}")
|
||||
|
||||
logger.error(f"Ошибка проверки статуса подписки: {e}")
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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}")
|
||||
|
||||
@@ -66,18 +66,36 @@ class MenuLayoutStatsService:
|
||||
callback_data: Optional[str] = None,
|
||||
button_type: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
) -> ButtonClickLog:
|
||||
) -> Optional[ButtonClickLog]:
|
||||
"""Записать клик по кнопке."""
|
||||
click_log = ButtonClickLog(
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text,
|
||||
)
|
||||
db.add(click_log)
|
||||
await db.commit()
|
||||
return click_log
|
||||
try:
|
||||
click_log = ButtonClickLog(
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text,
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
@@ -811,27 +825,41 @@ class MonitoringService:
|
||||
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
|
||||
current_time = datetime.utcnow()
|
||||
threshold_date = current_time + timedelta(days=days_before)
|
||||
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.is_trial == False,
|
||||
Subscription.is_trial == False,
|
||||
Subscription.end_date > current_time,
|
||||
Subscription.end_date <= threshold_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1893,7 +1893,60 @@ class RemnaWaveService:
|
||||
except Exception as e:
|
||||
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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -62,17 +62,35 @@ 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',
|
||||
'DAY': 'DAY',
|
||||
'DAY': 'DAY',
|
||||
'WEEK': 'WEEK',
|
||||
'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,
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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()
|
||||
+11
-2
@@ -12,17 +12,22 @@ class SubscriptionStates(StatesGroup):
|
||||
selecting_countries = State()
|
||||
selecting_devices = State()
|
||||
confirming_purchase = State()
|
||||
|
||||
|
||||
adding_countries = State()
|
||||
adding_traffic = State()
|
||||
adding_devices = State()
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
cart_saved_for_topup = State()
|
||||
|
||||
|
||||
# Состояния для простой подписки
|
||||
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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
+1282
-143
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+30
-20
@@ -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 # Подключаем к сети панели
|
||||
- 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
|
||||
@@ -29,9 +33,9 @@ services:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- bot_network
|
||||
- remnawave-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 # Подключаем к сети панели
|
||||
- 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,7 +98,9 @@ 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
|
||||
external: true # Используем существующую сеть панели
|
||||
external: true # Используем существующую сеть панели
|
||||
|
||||
+27
-17
@@ -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}"
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1987
-114
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user