Compare commits

...

113 Commits

Author SHA1 Message Date
Egor 5367874062 Update models.py 2025-08-27 13:20:32 +03:00
Egor 321696b5b5 Update remnawave_api.py 2025-08-27 13:19:07 +03:00
Egor b642a5550c Update universal_migration.py 2025-08-27 13:16:13 +03:00
Egor 0e97632099 Update texts.py 2025-08-27 05:50:43 +03:00
Egor 4a3c6c4124 Update formatters.py 2025-08-27 05:50:18 +03:00
Egor 3bf9c143d4 Update monitoring_service.py 2025-08-27 05:49:44 +03:00
Egor 285e5e256e Update balance.py 2025-08-26 18:48:03 +03:00
Egor 5b06b89d7a bedolaga screens 2025-08-26 17:01:38 +03:00
Egor 7e749fa096 Merge pull request #16 from Fr1ngg/FixDbCollisions
Fix db collisions
2025-08-26 03:27:43 +03:00
Egor 13ac3f0e74 Create subscription_utils.py 2025-08-26 03:22:47 +03:00
Egor 32c551999c Update models.py 2025-08-26 03:21:26 +03:00
Egor ff2c09d3be Update subscription.py 2025-08-26 03:20:47 +03:00
Egor 4c97ac4497 Update subscription.py 2025-08-26 03:20:05 +03:00
Egor 86516186d9 Update main.py 2025-08-26 03:18:31 +03:00
Egor e0581bdc3c Create universal_migration.py 2025-08-26 03:14:26 +03:00
Egor 0b7d8d41f6 Update monitoring_service.py 2025-08-25 05:47:31 +03:00
Egor 4ca46537f9 Update subscription_service.py 2025-08-25 05:06:28 +03:00
Egor 75f2d0f1ce Update remnawave.py 2025-08-25 05:05:13 +03:00
Egor 51424bf256 Update user.py 2025-08-25 05:04:01 +03:00
Egor 4cce2f1efb Update remnawave_service.py 2025-08-25 05:03:15 +03:00
Egor 5102286963 Update bot.py 2025-08-25 03:17:27 +03:00
Egor 24c8f25e6e Update subscription.py 2025-08-25 03:16:39 +03:00
Egor de4796ac1c Update menu.py 2025-08-25 03:16:22 +03:00
Egor 95a8da5f19 Update monitoring_service.py 2025-08-25 03:15:45 +03:00
Egor 76f1313550 Update subscription_service.py 2025-08-25 03:15:02 +03:00
Egor 58d22200cf Create subscription_checker.py 2025-08-25 03:14:19 +03:00
Egor eb8b09e658 Update subscription.py 2025-08-25 03:12:46 +03:00
Egor 25cf635b6f Update models.py 2025-08-25 03:12:22 +03:00
Egor a54c1001f9 Update models.py 2025-08-25 03:11:49 +03:00
Egor 6140d7042a Update subscription.py 2025-08-24 23:22:56 +03:00
Egor d0135a2328 Update subscription.py 2025-08-24 23:21:08 +03:00
Egor 1048734548 Update README.md 2025-08-24 18:29:43 +03:00
Egor 62116fd5ba Update README.md 2025-08-24 17:27:52 +03:00
Egor 37a905f056 Update states.py 2025-08-24 16:58:17 +03:00
Egor bce9061401 Update users.py 2025-08-24 16:57:29 +03:00
Egor 118b282a2b Update states.py 2025-08-24 16:52:11 +03:00
Egor 6c250ed36f Update users.py 2025-08-24 16:51:31 +03:00
Egor 3842aeacca Update subscription_service.py 2025-08-24 16:23:04 +03:00
Egor 1d1afbe1da Update remnawave_service.py 2025-08-24 16:21:54 +03:00
Egor d4db55ea66 Update subscription.py 2025-08-24 16:19:32 +03:00
Egor 354964060c Update README.md 2025-08-23 18:25:49 +03:00
Egor 5bc8d8e52c Update README.md 2025-08-23 18:25:08 +03:00
Egor 8f3e8bacc4 Update README.md 2025-08-23 18:24:38 +03:00
Egor 61c94e17a4 Update promocode_service.py 2025-08-23 16:04:54 +03:00
Egor 77c52c1197 Update README.md 2025-08-23 14:38:43 +03:00
Egor c65c703e00 Update README.md 2025-08-23 12:56:42 +03:00
Egor 7560e802cb Update README.md 2025-08-23 12:53:05 +03:00
Egor a5a90a806b Update README.md 2025-08-23 12:35:37 +03:00
Egor aa0a271d14 Update README.md 2025-08-23 12:31:25 +03:00
Egor 5d4e51c862 Update README.md 2025-08-23 12:16:27 +03:00
Egor e6057bb93c Update .env.example 2025-08-23 11:58:48 +03:00
Egor 714bdd0d7c Update inline.py 2025-08-23 11:52:42 +03:00
Egor 41f3eb76c1 Update subscription.py 2025-08-23 11:51:12 +03:00
Egor e9c58b3f76 Update config.py 2025-08-23 11:48:26 +03:00
Egor 3d6976ff6e Update config.py 2025-08-23 11:44:40 +03:00
Egor ee978dff7b Add files via upload 2025-08-23 11:41:20 +03:00
Egor 0bcf5c5f43 Create 3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-08-23 11:31:06 +03:00
Egor 2ceedd6587 Update README.md 2025-08-23 07:41:34 +03:00
Egor 2ded1a4a9e Update README.md 2025-08-23 07:09:06 +03:00
Egor d551a7267f Update README.md 2025-08-23 06:27:24 +03:00
Egor 3ddf6b35c6 Update .env.example 2025-08-23 06:26:37 +03:00
Egor c0d3c70683 Update inline.py 2025-08-23 06:25:15 +03:00
Egor 7267433d99 Update subscription.py 2025-08-23 06:22:41 +03:00
Egor 09f3c4d27f Update config.py 2025-08-23 06:21:43 +03:00
Egor 3083915bc8 Update remnawave_api.py 2025-08-22 15:18:21 +03:00
Egor 5c72c04d27 Update texts.py 2025-08-22 12:25:42 +03:00
Egor 826149da3d Update balance.py 2025-08-22 12:19:14 +03:00
Egor f090be328f Update menu.py 2025-08-22 11:40:57 +03:00
Egor ecbfcd2b54 Update texts.py 2025-08-22 11:40:27 +03:00
Egor 2d21b1c542 Update balance.py 2025-08-22 11:17:22 +03:00
Egor c63438af5e Update monitoring_service.py 2025-08-22 11:11:52 +03:00
Egor 8e47013ae2 Update texts.py 2025-08-22 11:09:54 +03:00
Egor 92fa0ebcc1 Update inline.py 2025-08-22 11:09:19 +03:00
Egor f1d23616f1 Update balance.py 2025-08-22 11:08:37 +03:00
Egor d554f2d801 Create subscription_notifications.py 2025-08-22 11:07:10 +03:00
Egor 1fe4608604 Update monitoring_service.py 2025-08-22 11:05:41 +03:00
Egor bea534b352 Update README.md 2025-08-22 09:17:46 +03:00
Egor e993c19a34 Update README.md 2025-08-22 09:14:40 +03:00
Egor 2deb5d1bc1 Update README.md 2025-08-22 09:06:46 +03:00
Egor fb1b0bbcd5 Update README.md 2025-08-22 09:02:36 +03:00
Egor 0308dd6c58 Update README.md 2025-08-22 09:02:15 +03:00
Egor 860bf7fc7a Update user_service.py 2025-08-22 08:01:34 +03:00
Egor c24024db18 Update user_service.py 2025-08-22 07:50:18 +03:00
Egor 7d330838e0 Update auth.py 2025-08-22 07:48:45 +03:00
Egor feed3fff0d Update start.py 2025-08-22 07:47:36 +03:00
Egor 4ddaafd052 Update requirements.txt 2025-08-22 07:24:08 +03:00
Egor 140194a0ad Update admin.py 2025-08-22 07:21:56 +03:00
Egor 7c6adae87f Update user_service.py 2025-08-22 07:21:19 +03:00
Egor 03deb91a25 Update auth.py 2025-08-22 07:20:20 +03:00
Egor ea2caa98ae Update users.py 2025-08-22 07:19:40 +03:00
Egor dab69b9229 Update bot.py 2025-08-22 07:18:40 +03:00
Egor 0ffa86ff36 Update subscription.py 2025-08-22 02:30:55 +03:00
Egor 3b08b71472 Update inline.py 2025-08-22 02:30:23 +03:00
Egor 6c7b1353df Update subscription.py 2025-08-22 02:29:51 +03:00
Egor 9e009d2ded Update subscription_service.py 2025-08-22 02:29:11 +03:00
Egor dc6a829f0f Update texts.py 2025-08-22 01:12:26 +03:00
Egor 3a3a4aa9aa Update inline.py 2025-08-22 01:05:00 +03:00
Egor 99cd22d76b Update subscription.py 2025-08-22 01:04:42 +03:00
Egor 0a920bffe6 Update remnawave.py 2025-08-22 00:22:37 +03:00
Egor 55fbc65231 Update remnawave_service.py 2025-08-22 00:22:05 +03:00
Egor f508ac151b Update admin.py 2025-08-22 00:21:36 +03:00
Egor abec12eed1 Update subscription.py 2025-08-21 21:33:40 +03:00
Egor 9b50fbbe05 Update inline.py 2025-08-21 21:33:19 +03:00
Egor 9311e7c11d Update config.py 2025-08-21 06:11:53 +03:00
Egor 625972da87 Update inline.py 2025-08-21 06:11:08 +03:00
Egor d3293615b0 Update subscription.py 2025-08-21 06:09:28 +03:00
Egor eef1fcc705 Create app-config.json 2025-08-21 06:06:27 +03:00
Egor ffe8259025 Update README.md 2025-08-21 04:48:28 +03:00
Egor bac42b51e1 Update README.md 2025-08-21 02:46:20 +03:00
Egor 510ec2990d Update README.md 2025-08-21 02:40:21 +03:00
Egor dc06056cc7 Update README.md 2025-08-21 02:37:02 +03:00
Egor 103d6e8854 Update .env.example 2025-08-21 02:34:31 +03:00
Egor 466f1e66cc Update README.md 2025-08-21 02:34:15 +03:00
61 changed files with 6485 additions and 1331 deletions
+20 -1
View File
@@ -11,6 +11,17 @@ REDIS_URL=redis://localhost:6379/0
REMNAWAVE_API_URL=
REMNAWAVE_API_KEY=
# === NEW: Traffic Selection Mode Settings ===
# Режим выбора трафика:
# "selectable" - пользователи выбирают пакеты трафика (по умолчанию)
# "fixed" - фиксированный лимит трафика для всех подписок, доступно 5/10/25/50/100/250/0 (0 безлимит) гб
TRAFFIC_SELECTION_MODE=selectable
# Фиксированный лимит трафика в ГБ (используется только в режиме "fixed")
# 0 = безлимит
# для "fixed" обязательно должы быть проставлены цены на пакеты 5/10/25/50/100/250/0 можно постать 0 руб - будет беслпатно
FIXED_TRAFFIC_LIMIT_GB=0
# TRIAL SUBSCRIPTION SETTINGS
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
@@ -43,6 +54,14 @@ REFERRAL_REGISTRATION_REWARD=5000
REFERRED_USER_REWARD=10000
REFERRAL_COMMISSION_PERCENT=25
# Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
CONNECT_BUTTON_MODE=miniapp_subscription
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
# MINIAPP_CUSTOM_URL=
# AUTO-PAYMENT SETTINGS
AUTOPAY_WARNING_DAYS=3,1
@@ -73,7 +92,7 @@ AVAILABLE_LANGUAGES=ru
# LOGGING
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
LOG_FILE=/tmp/bot.log
# DEVELOPMENT
DEBUG=false
+1129 -599
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+163
View File
@@ -0,0 +1,163 @@
{
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "App Store [EU]",
"ru": "App Store [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"ru": "App Store [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"ru": "Скачать APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Откройте приложение и подключитесь к серверу"
}
}
}
],
"pc": [
{
"id": "happ",
"name": "Happ",
"isFeatured": false,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
"buttonText": {
"en": "Mac Os",
"ru": "Mac Os"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
"buttonText": {
"en": "Windows",
"ru": "Windows"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server",
"ru": "Выберете локацию, включить VPN"
}
}
}
],
"tv": [
{
"id": "vpn4tv",
"name": "VPN4TV",
"isFeatured": true,
"urlScheme": "",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
}
}
}
]
}
+12 -2
View File
@@ -8,6 +8,7 @@ from app.config import settings
from app.middlewares.auth import AuthMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.throttling import ThrottlingMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.utils.cache import cache
from app.handlers import (
@@ -40,7 +41,13 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning(f"⚠️ Кеш не инициализирован: {e}")
bot = Bot(token=settings.BOT_TOKEN, parse_mode="HTML")
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
try:
redis_client = redis.from_url(settings.REDIS_URL)
@@ -60,6 +67,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(AuthMiddleware())
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
dp.message.middleware(SubscriptionStatusMiddleware())
dp.callback_query.middleware(SubscriptionStatusMiddleware())
start.register_handlers(dp)
menu.register_handlers(dp)
@@ -85,4 +95,4 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
logger.info("✅ Бот успешно настроен")
return bot, dp
return bot, dp
+33 -1
View File
@@ -49,6 +49,9 @@ class Settings(BaseSettings):
PRICE_PER_DEVICE: int = 5000
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
REFERRAL_REGISTRATION_REWARD: int = 5000
REFERRED_USER_REWARD: int = 2500
REFERRAL_COMMISSION_PERCENT: int = 10
@@ -69,6 +72,9 @@ class Settings(BaseSettings):
TRIBUTE_DONATE_LINK: Optional[str] = None
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
TRIBUTE_WEBHOOK_PORT: int = 8081
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
@@ -80,6 +86,10 @@ class Settings(BaseSettings):
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
APP_CONFIG_PATH: str = "app-config.json"
ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600
@field_validator('LOG_FILE', mode='before')
@classmethod
def ensure_log_dir(cls, v):
@@ -142,6 +152,28 @@ class Settings(BaseSettings):
def is_notifications_enabled(self) -> bool:
return self.ENABLE_NOTIFICATIONS
def get_app_config_path(self) -> str:
if os.path.isabs(self.APP_CONFIG_PATH):
return self.APP_CONFIG_PATH
project_root = Path(__file__).parent.parent
return str(project_root / self.APP_CONFIG_PATH)
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL
def is_traffic_selectable(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
def is_traffic_fixed(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8"
@@ -168,4 +200,4 @@ TRAFFIC_PRICES = {
100: settings.PRICE_TRAFFIC_100GB,
250: settings.PRICE_TRAFFIC_250GB,
0: settings.PRICE_TRAFFIC_UNLIMITED,
}
}
+126 -5
View File
@@ -19,8 +19,15 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
select(Subscription)
.options(selectinload(Subscription.user))
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
subscription = result.scalar_one_or_none()
if subscription:
subscription = await check_and_update_subscription_status(db, subscription)
return subscription
async def create_trial_subscription(
@@ -93,17 +100,30 @@ async def extend_subscription(
subscription: Subscription,
days: int
) -> Subscription:
current_time = datetime.utcnow()
subscription.extend_subscription(days)
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
if subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
if subscription.status == SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
subscription.updated_at = current_time
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f" Подписка пользователя {subscription.user_id} продлена на {days} дней")
logger.info(f" Подписка продлена до: {subscription.end_date}")
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
return subscription
@@ -444,6 +464,107 @@ async def get_subscription_servers(
return servers_info
async def remove_subscription_servers(
db: AsyncSession,
subscription_id: int,
server_squad_ids: List[int]
) -> bool:
try:
from app.database.models import SubscriptionServer
from sqlalchemy import delete
await db.execute(
delete(SubscriptionServer)
.where(
SubscriptionServer.subscription_id == subscription_id,
SubscriptionServer.server_squad_id.in_(server_squad_ids)
)
)
await db.commit()
logger.info(f"🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}")
return True
except Exception as e:
logger.error(f"Ошибка удаления серверов из подписки: {e}")
await db.rollback()
return False
async def get_subscription_renewal_cost(
db: AsyncSession,
subscription_id: int,
period_days: int
) -> int:
try:
from app.config import PERIOD_PRICES, TRAFFIC_PRICES, settings
base_price = PERIOD_PRICES.get(period_days, 0)
servers_info = await get_subscription_servers(db, subscription_id)
servers_cost = sum(server_info['paid_price_kopeks'] for server_info in servers_info)
subscription = await db.get(Subscription, subscription_id)
if not subscription:
return base_price
traffic_cost = 0
if subscription.traffic_limit_gb > 0:
traffic_cost = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
devices_cost = max(0, subscription.device_limit - 1) * settings.PRICE_PER_DEVICE
total_cost = base_price + servers_cost + traffic_cost + devices_cost
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней:")
logger.info(f" 📅 Период: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_cost/100}")
logger.info(f" 📊 Трафик: {traffic_cost/100}")
logger.info(f" 📱 Устройства: {devices_cost/100}")
logger.info(f" 💎 ИТОГО: {total_cost/100}")
return total_cost
except Exception as e:
logger.error(f"Ошибка расчета стоимости продления: {e}")
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
async def expire_subscription(
db: AsyncSession,
subscription: Subscription
) -> Subscription:
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая")
return subscription
async def check_and_update_subscription_status(
db: AsyncSession,
subscription: Subscription
) -> Subscription:
current_time = datetime.utcnow()
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'")
return subscription
async def create_subscription(
db: AsyncSession,
user_id: int,
@@ -482,4 +603,4 @@ async def create_subscription(
await db.refresh(subscription)
logger.info(f"✅ Создана подписка для пользователя {user_id}")
return subscription
return subscription
+27 -24
View File
@@ -150,33 +150,36 @@ async def add_user_balance(
async def subtract_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str = "Списание с баланса"
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
if not user.subtract_balance(amount_kopeks):
if user.balance_kopeks < amount_kopeks:
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
return False
user.updated_at = datetime.utcnow()
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.WITHDRAWAL,
amount_kopeks=amount_kopeks,
description=description
)
await db.commit()
await db.refresh(user)
logger.info(f"💸 Списан баланс пользователя {user.telegram_id}: -{amount_kopeks/100}")
return True
try:
old_balance = user.balance_kopeks
user.balance_kopeks -= amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
except Exception as e:
logger.error(f" ❌ ОШИБКА СПИСАНИЯ: {e}")
await db.rollback()
return False
async def get_users_list(
@@ -324,4 +327,4 @@ async def get_users_statistics(db: AsyncSession) -> dict:
"new_today": new_today,
"new_week": new_week,
"new_month": new_month
}
}
+103 -17
View File
@@ -31,7 +31,7 @@ class TransactionType(Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund" # Возврат
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
@@ -55,31 +55,27 @@ class User(Base):
username = Column(String(255), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
status = Column(String(20), default=UserStatus.ACTIVE.value)
language = Column(String(5), default="ru")
balance_kopeks = Column(Integer, default=0)
used_promocodes = Column(Integer, default=0)
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
referral_code = Column(String(20), unique=True, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_activity = Column(DateTime, default=func.now())
remnawave_uuid = Column(String(255), nullable=True, unique=True)
broadcasts = relationship("BroadcastHistory", back_populates="admin")
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
subscription = relationship("Subscription", back_populates="user", uselist=False)
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
@property
def balance_rubles(self) -> float:
@@ -104,7 +100,7 @@ class Subscription(Base):
__tablename__ = "subscriptions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
is_trial = Column(Boolean, default=True)
@@ -133,21 +129,111 @@ class Subscription(Base):
@property
def is_active(self) -> bool:
current_time = datetime.utcnow()
return (
self.status == SubscriptionStatus.ACTIVE.value and
self.end_date > datetime.utcnow()
self.end_date > current_time
)
@property
def is_expired(self) -> bool:
"""Проверяет, истёк ли срок подписки"""
return self.end_date <= datetime.utcnow()
@property
def should_be_expired(self) -> bool:
current_time = datetime.utcnow()
return (
self.status == SubscriptionStatus.ACTIVE.value and
self.end_date <= current_time
)
@property
def actual_status(self) -> str:
current_time = datetime.utcnow()
if self.status == SubscriptionStatus.EXPIRED.value:
return "expired"
if self.status == SubscriptionStatus.DISABLED.value:
return "disabled"
if self.status == SubscriptionStatus.ACTIVE.value:
if self.end_date <= current_time:
return "expired"
else:
return "active"
if self.status == SubscriptionStatus.TRIAL.value:
if self.end_date <= current_time:
return "expired"
else:
return "trial"
return self.status
@property
def status_display(self) -> str:
actual_status = self.actual_status
current_time = datetime.utcnow()
if actual_status == "expired":
return "🔴 Истекла"
elif actual_status == "active":
if self.is_trial:
return "🎯 Тестовая"
else:
return "🟢 Активна"
elif actual_status == "disabled":
return "⚫ Отключена"
elif actual_status == "trial":
return "🎯 Тестовая"
return "❓ Неизвестно"
@property
def status_emoji(self) -> str:
actual_status = self.actual_status
if actual_status == "expired":
return "🔴"
elif actual_status == "active":
if self.is_trial:
return "🎁"
else:
return "💎"
elif actual_status == "disabled":
return ""
elif actual_status == "trial":
return "🎁"
return ""
@property
def days_left(self) -> int:
if self.is_expired:
current_time = datetime.utcnow()
if self.end_date <= current_time:
return 0
delta = self.end_date - datetime.utcnow()
return delta.days
delta = self.end_date - current_time
return max(0, delta.days)
@property
def time_left_display(self) -> str:
current_time = datetime.utcnow()
if self.end_date <= current_time:
return "истёк"
delta = self.end_date - current_time
days = delta.days
hours = delta.seconds // 3600
minutes = (delta.seconds % 3600) // 60
if days > 0:
return f"{days} дн."
elif hours > 0:
return f"{hours} ч."
else:
return f"{minutes} мин."
@property
def traffic_used_percent(self) -> float:
@@ -418,4 +504,4 @@ class SubscriptionServer(Base):
paid_price_kopeks = Column(Integer, default=0)
subscription = relationship("Subscription", backref="subscription_servers")
server_squad = relationship("ServerSquad", backref="subscription_servers")
server_squad = relationship("ServerSquad", backref="subscription_servers")
+275
View File
@@ -0,0 +1,275 @@
import logging
from sqlalchemy import text, inspect
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import engine
logger = logging.getLogger(__name__)
async def get_database_type():
"""Определяет тип базы данных"""
return engine.dialect.name
async def check_unique_constraint_exists():
"""Проверяет, существует ли ограничение уникальности на user_id"""
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
result = await conn.execute(text("PRAGMA table_info(subscriptions)"))
columns = result.fetchall()
check_result = await conn.execute(text("""
SELECT user_id, COUNT(*) as count
FROM subscriptions
GROUP BY user_id
HAVING COUNT(*) > 1
LIMIT 1
"""))
duplicates = check_result.fetchall()
return len(duplicates) == 0
elif db_type == 'postgresql':
result = await conn.execute(text("""
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'subscriptions'
AND constraint_type = 'UNIQUE'
AND constraint_name LIKE '%user_id%'
"""))
constraints = result.fetchall()
return len(constraints) > 0
elif db_type == 'mysql':
result = await conn.execute(text("""
SELECT CONSTRAINT_NAME
FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_NAME = 'subscriptions'
AND CONSTRAINT_TYPE = 'UNIQUE'
AND CONSTRAINT_NAME LIKE '%user_id%'
"""))
constraints = result.fetchall()
return len(constraints) > 0
return False
except Exception as e:
logger.error(f"Ошибка проверки ограничения уникальности: {e}")
return False
async def check_column_exists(table_name: str, column_name: str) -> bool:
"""Проверяет, существует ли колонка в таблице"""
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
result = await conn.execute(text(f"PRAGMA table_info({table_name})"))
columns = result.fetchall()
return any(col[1] == column_name for col in columns)
elif db_type == 'postgresql':
result = await conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = :table_name
AND column_name = :column_name
"""), {"table_name": table_name, "column_name": column_name})
return result.fetchone() is not None
elif db_type == 'mysql':
result = await conn.execute(text("""
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name
"""), {"table_name": table_name, "column_name": column_name})
return result.fetchone() is not None
return False
except Exception as e:
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
return False
async def add_remnawave_v2_columns():
"""Добавляет колонки для поддержки RemnaWave API v2.1.5"""
columns_to_add = {
'lifetime_used_traffic_bytes': 'BIGINT DEFAULT 0',
'last_remnawave_sync': 'TIMESTAMP NULL',
'trojan_password': 'VARCHAR(255) NULL',
'vless_uuid': 'VARCHAR(255) NULL',
'ss_password': 'VARCHAR(255) NULL'
}
logger.info("=== ПРОВЕРКА КОЛОНОК REMNAWAVE V2.1.5 ===")
try:
async with engine.begin() as conn:
db_type = await get_database_type()
columns_added = 0
for column_name, column_def in columns_to_add.items():
exists = await check_column_exists('users', column_name)
if not exists:
logger.info(f"Добавление колонки {column_name} в таблицу users")
if db_type == 'sqlite':
if column_def.startswith('BIGINT'):
column_def = column_def.replace('BIGINT', 'INTEGER')
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
elif db_type == 'mysql':
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
try:
await conn.execute(text(f"ALTER TABLE users ADD COLUMN {column_name} {column_def}"))
columns_added += 1
logger.info(f"Колонка {column_name} успешно добавлена")
except Exception as e:
logger.error(f"Ошибка добавления колонки {column_name}: {e}")
continue
else:
logger.debug(f"Колонка {column_name} уже существует")
if columns_added > 0:
logger.info(f"Добавлено {columns_added} новых колонок для RemnaWave v2.1.5")
else:
logger.info("Все колонки RemnaWave v2.1.5 уже существуют")
return columns_added
except Exception as e:
logger.error(f"Ошибка при добавлении колонок RemnaWave v2.1.5: {e}")
return 0
async def fix_subscription_duplicates_universal():
"""Универсальная функция очистки дубликатов для разных типов БД"""
async with engine.begin() as conn:
db_type = await get_database_type()
logger.info(f"Обнаружен тип базы данных: {db_type}")
try:
result = await conn.execute(text("""
SELECT user_id, COUNT(*) as count
FROM subscriptions
GROUP BY user_id
HAVING COUNT(*) > 1
"""))
duplicates = result.fetchall()
if not duplicates:
logger.info("Дублирующихся подписок не найдено")
return 0
logger.info(f"Найдено {len(duplicates)} пользователей с дублирующимися подписками")
total_deleted = 0
for user_id_row, count in duplicates:
user_id = user_id_row
if db_type == 'sqlite':
delete_result = await conn.execute(text("""
DELETE FROM subscriptions
WHERE user_id = :user_id AND id NOT IN (
SELECT MAX(id)
FROM subscriptions
WHERE user_id = :user_id
)
"""), {"user_id": user_id})
elif db_type in ['postgresql', 'mysql']:
delete_result = await conn.execute(text("""
DELETE FROM subscriptions
WHERE user_id = :user_id AND id NOT IN (
SELECT max_id FROM (
SELECT MAX(id) as max_id
FROM subscriptions
WHERE user_id = :user_id
) as subquery
)
"""), {"user_id": user_id})
else:
subs_result = await conn.execute(text("""
SELECT id FROM subscriptions
WHERE user_id = :user_id
ORDER BY created_at DESC, id DESC
"""), {"user_id": user_id})
sub_ids = [row[0] for row in subs_result.fetchall()]
if len(sub_ids) > 1:
ids_to_delete = sub_ids[1:]
for sub_id in ids_to_delete:
await conn.execute(text("""
DELETE FROM subscriptions WHERE id = :id
"""), {"id": sub_id})
delete_result = type('Result', (), {'rowcount': len(ids_to_delete)})()
else:
delete_result = type('Result', (), {'rowcount': 0})()
deleted_count = delete_result.rowcount
total_deleted += deleted_count
logger.info(f"Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}")
logger.info(f"Всего удалено дублирующихся подписок: {total_deleted}")
return total_deleted
except Exception as e:
logger.error(f"Ошибка при очистке дублирующихся подписок: {e}")
raise
async def run_universal_migration():
"""Запускает универсальную миграцию"""
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ПОДПИСОК ===")
try:
db_type = await get_database_type()
logger.info(f"Тип базы данных: {db_type}")
await add_remnawave_v2_columns()
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"))
total_count = total_subs.fetchone()[0]
unique_count = unique_users.fetchone()[0]
logger.info(f"Всего подписок: {total_count}")
logger.info(f"Уникальных пользователей: {unique_count}")
if total_count == unique_count:
logger.info("База данных уже в корректном состоянии")
return True
deleted_count = await fix_subscription_duplicates_universal()
async with engine.begin() as conn:
final_check = await conn.execute(text("""
SELECT user_id, COUNT(*) as count
FROM subscriptions
GROUP BY user_id
HAVING COUNT(*) > 1
"""))
remaining_duplicates = final_check.fetchall()
if remaining_duplicates:
logger.warning(f"Остались дубликаты у {len(remaining_duplicates)} пользователей")
return False
else:
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
return True
except Exception as e:
logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===")
return False
+135 -59
View File
@@ -1,7 +1,9 @@
import asyncio
import json
import ssl
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Union, Any
from urllib.parse import urlparse
import aiohttp
import logging
from dataclasses import dataclass
@@ -31,7 +33,7 @@ class RemnaWaveUser:
username: str
status: UserStatus
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
lifetime_used_traffic_bytes: int # Новое поле
traffic_limit_bytes: int
traffic_limit_strategy: TrafficLimitStrategy
expire_at: datetime
@@ -44,6 +46,16 @@ class RemnaWaveUser:
active_internal_squads: List[Dict[str, str]]
created_at: datetime
updated_at: datetime
sub_last_user_agent: Optional[str] = None
sub_last_opened_at: Optional[datetime] = None
online_at: Optional[datetime] = None
sub_revoked_at: Optional[datetime] = None
last_traffic_reset_at: Optional[datetime] = None
trojan_password: Optional[str] = None
vless_uuid: Optional[str] = None
ss_password: Optional[str] = None
first_connected_at: Optional[datetime] = None
last_triggered_threshold: int = 0
@dataclass
@@ -85,13 +97,65 @@ class RemnaWaveAPI:
self.api_key = api_key
self.session: Optional[aiohttp.ClientSession] = None
def _detect_connection_type(self) -> str:
parsed = urlparse(self.base_url)
local_hosts = [
'localhost', '127.0.0.1', 'remnawave',
'remnawave-backend', 'app', 'api'
]
if parsed.hostname in local_hosts:
return "local"
if parsed.hostname:
if (parsed.hostname.startswith('192.168.') or
parsed.hostname.startswith('10.') or
parsed.hostname.startswith('172.') or
parsed.hostname.endswith('.local')):
return "local"
return "external"
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.info(f"🔗 Подключение к RemnaWave: {self.base_url} (тип: {conn_type})")
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
connector_kwargs = {}
if conn_type == "local":
logger.debug("🏠 Использую локальные заголовки proxy")
headers.update({
'X-Forwarded-For': '127.0.0.1',
'X-Forwarded-Proto': 'https',
'X-Forwarded-Host': 'localhost',
'X-Real-IP': '127.0.0.1',
'Host': 'localhost'
})
if self.base_url.startswith('https://'):
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector_kwargs['ssl'] = ssl_context
logger.debug("🔓 SSL проверка отключена для локального HTTPS")
elif conn_type == "external":
logger.debug("🌐 Использую внешнее подключение с полной SSL проверкой")
pass
connector = aiohttp.TCPConnector(**connector_kwargs)
self.session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
headers={
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
headers=headers,
connector=connector
)
return self
@@ -375,6 +439,56 @@ class RemnaWaveAPI:
return response['response']
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
return response['response']
except RemnaWaveAPIError as e:
if e.status_code == 404:
return {'total': 0, 'devices': []}
raise
async def reset_user_devices(self, user_uuid: str) -> bool:
try:
devices_info = await self.get_user_devices(user_uuid)
devices = devices_info.get('devices', [])
if not devices:
return True
failed_count = 0
for device in devices:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
except Exception as device_error:
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
failed_count += 1
return failed_count < len(devices) / 2
except Exception as e:
logger.error(f"Ошибка при сбросе устройств: {e}")
return False
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
return True
except Exception as e:
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
return False
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
return RemnaWaveUser(
uuid=user_data['uuid'],
@@ -394,8 +508,23 @@ class RemnaWaveAPI:
subscription_url=user_data['subscriptionUrl'],
active_internal_squads=user_data['activeInternalSquads'],
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00'))
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
sub_last_user_agent=user_data.get('subLastUserAgent'),
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
trojan_password=user_data.get('trojanPassword'),
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
if date_str:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return None
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
return RemnaWaveInternalSquad(
@@ -422,7 +551,6 @@ class RemnaWaveAPI:
)
def format_bytes(bytes_value: int) -> str:
if bytes_value == 0:
return "0 B"
@@ -467,55 +595,3 @@ async def test_api_connection(api: RemnaWaveAPI) -> bool:
except Exception as e:
logger.error(f"API connection test failed: {e}")
return False
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
return response['response']
except RemnaWaveAPIError as e:
if e.status_code == 404:
return {'total': 0, 'devices': []}
raise
async def reset_user_devices(self, user_uuid: str) -> bool:
try:
devices_info = await self.get_user_devices(user_uuid)
devices = devices_info.get('devices', [])
if not devices:
return True
failed_count = 0
for device in devices:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
except Exception as device_error:
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
failed_count += 1
return failed_count < len(devices) / 2
except Exception as e:
logger.error(f"Ошибка при сбросе устройств: {e}")
return False
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
return True
except Exception as e:
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
return False
+446 -31
View File
@@ -1315,27 +1315,350 @@ async def restart_all_nodes(
@admin_required
@error_handler
async def show_sync_options(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = """
text = """
🔄 <b>Синхронизация с RemnaWave</b>
Выберите тип синхронизации:
- <b>Синхронизировать всех</b> - полная синхронизация всех пользователей
- <b>Только новых</b> - создание пользователей из панели, которых нет в боте
- <b>Обновить данные</b> - обновление информации о трафике и подписках
🔄 <b>Синхронизировать всех</b>
• Полная синхронизация всех пользователей
• Создание новых пользователей из панели
• Обновление данных существующих
• Удаление неактуальных подписок
• ⏱️ Время выполнения: 2-5 минут
⚠️ Процесс может занять несколько минут
🆕 <b>Только новых</b>
• Создание пользователей из панели, которых нет в боте
• Быстрое добавление при массовой регистрации
• ⏱️ Время выполнения: 30 секунд - 2 минуты
📈 <b>Обновить данные</b>
• Обновление информации о трафике и подписках
• Синхронизация статуса и лимитов
• Обновление подключенных сквадов
• ⏱️ Время выполнения: 1-3 минуты
🔍 <b>Валидация подписок</b>
• Проверка и исправление проблем в данных
• Восстановление отсутствующих полей
• Исправление некорректных статусов
🧹 <b>Мягкая очистка</b>
• Деактивация подписок отсутствующих в панели
• Сохранение транзакций и истории
🗑️ <b>ПРИНУДИТЕЛЬНАЯ ОЧИСТКА</b>
• ⚠️ ОПАСНО: Полное удаление данных пользователей
• Удаление транзакций, балансов, рефералов
• Только при серьезных проблемах синхронизации
⚠️ <b>Важно:</b>
• Во время синхронизации не выполняйте другие операции
• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы
• Рекомендуется делать полную синхронизацию ежедневно
"""
await callback.message.edit_text(
text,
reply_markup=get_sync_options_keyboard(db_user.language)
)
await callback.answer()
keyboard = [
[types.InlineKeyboardButton(text="🔄 Синхронизировать всех", callback_data="sync_all_users")],
[types.InlineKeyboardButton(text="🆕 Только новых", callback_data="sync_new_users")],
[types.InlineKeyboardButton(text="📈 Обновить данные", callback_data="sync_update_data")],
[types.InlineKeyboardButton(text="🔍 Валидация подписок", callback_data="sync_validate")],
[types.InlineKeyboardButton(text="🧹 Мягкая очистка", callback_data="sync_cleanup")],
[types.InlineKeyboardButton(text="🗑️ ПРИНУДИТЕЛЬНАЯ ОЧИСТКА", callback_data="confirm_force_cleanup")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_sync_recommendations(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"🔍 Анализируем состояние синхронизации...",
reply_markup=None
)
remnawave_service = RemnaWaveService()
recommendations = await remnawave_service.get_sync_recommendations(db)
priority_emoji = {
"low": "🟢",
"medium": "🟡",
"high": "🔴"
}
text = f"""
💡 <b>Рекомендации по синхронизации</b>
{priority_emoji.get(recommendations['priority'], '🟢')} <b>Приоритет:</b> {recommendations['priority'].upper()}
⏱️ <b>Время выполнения:</b> {recommendations['estimated_time']}
<b>Рекомендуемое действие:</b>
"""
if recommendations['sync_type'] == 'all':
text += "🔄 Полная синхронизация"
elif recommendations['sync_type'] == 'update_only':
text += "📈 Обновление данных"
elif recommendations['sync_type'] == 'new_only':
text += "🆕 Синхронизация новых"
else:
text += "✅ Синхронизация не требуется"
text += "\n\n<b>Причины:</b>\n"
for reason in recommendations['reasons']:
text += f"{reason}\n"
keyboard = []
if recommendations['should_sync'] and recommendations['sync_type'] != 'none':
keyboard.append([
types.InlineKeyboardButton(
text=f"✅ Выполнить рекомендацию",
callback_data=f"sync_{recommendations['sync_type']}_users" if recommendations['sync_type'] != 'update_only' else "sync_update_data"
)
])
keyboard.extend([
[types.InlineKeyboardButton(text="🔄 Другие опции", callback_data="admin_rw_sync")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def validate_subscriptions(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"🔍 Выполняется валидация подписок...\n\nПроверяем данные, может занять несколько минут.",
reply_markup=None
)
remnawave_service = RemnaWaveService()
stats = await remnawave_service.validate_and_fix_subscriptions(db)
if stats['errors'] == 0:
status_emoji = ""
status_text = "успешно завершена"
else:
status_emoji = "⚠️"
status_text = "завершена с ошибками"
text = f"""
{status_emoji} <b>Валидация {status_text}</b>
📊 <b>Результаты:</b>
• 🔍 Проверено подписок: {stats['checked']}
• 🔧 Исправлено подписок: {stats['fixed']}
• ⚠️ Найдено проблем: {stats['issues_found']}
• ❌ Ошибок: {stats['errors']}
"""
if stats['fixed'] > 0:
text += "\n✅ <b>Исправленные проблемы:</b>\n"
text += "• Статусы просроченных подписок\n"
text += "• Отсутствующие данные RemnaWave\n"
text += "• Некорректные лимиты трафика\n"
text += "• Настройки устройств\n"
if stats['errors'] > 0:
text += f"\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации."
keyboard = [
[types.InlineKeyboardButton(text="🔄 Повторить валидацию", callback_data="sync_validate")],
[types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
[types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def cleanup_subscriptions(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"🧹 Выполняется очистка неактуальных подписок...\n\nУдаляем подписки пользователей, отсутствующих в панели.",
reply_markup=None
)
remnawave_service = RemnaWaveService()
stats = await remnawave_service.cleanup_orphaned_subscriptions(db)
if stats['errors'] == 0:
status_emoji = ""
status_text = "успешно завершена"
else:
status_emoji = "⚠️"
status_text = "завершена с ошибками"
text = f"""
{status_emoji} <b>Очистка {status_text}</b>
📊 <b>Результаты:</b>
• 🔍 Проверено подписок: {stats['checked']}
• 🗑️ Деактивировано: {stats['deactivated']}
• ❌ Ошибок: {stats['errors']}
"""
if stats['deactivated'] > 0:
text += f"\n🗑️ <b>Деактивированные подписки:</b>\n"
text += f"Отключены подписки пользователей, которые\n"
text += f"отсутствуют в панели RemnaWave.\n"
else:
text += f"\n✅ Все подписки актуальны!\nНеактуальных подписок не найдено."
if stats['errors'] > 0:
text += f"\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации."
keyboard = [
[types.InlineKeyboardButton(text="🔄 Повторить очистку", callback_data="sync_cleanup")],
[types.InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate")],
[types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def force_cleanup_all_orphaned_users(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"🗑️ Выполняется принудительная очистка всех пользователей, отсутствующих в панели...\n\n"
"⚠️ ВНИМАНИЕ: Это полностью удалит ВСЕ данные пользователей!\n"
"📊 Включая: транзакции, реферальные доходы, промокоды, серверы, балансы\n\n"
"⏳ Пожалуйста, подождите...",
reply_markup=None
)
remnawave_service = RemnaWaveService()
stats = await remnawave_service.cleanup_orphaned_subscriptions(db)
if stats['errors'] == 0:
status_emoji = ""
status_text = "успешно завершена"
else:
status_emoji = "⚠️"
status_text = "завершена с ошибками"
text = f"""
{status_emoji} <b>Принудительная очистка {status_text}</b>
📊 <b>Результаты:</b>
• 🔍 Проверено подписок: {stats['checked']}
• 🗑️ Полностью очищено: {stats['deactivated']}
• ❌ Ошибок: {stats['errors']}
"""
if stats['deactivated'] > 0:
text += f"""
🗑️ <b>Полностью очищенные данные:</b>
• Подписки сброшены к начальному состоянию
• Удалены ВСЕ транзакции пользователей
• Удалены ВСЕ реферальные доходы
• Удалены использования промокодов
• Сброшены балансы к нулю
• Удалены подключенные серверы
• Сброшены HWID устройства в RemnaWave
• Очищены RemnaWave UUID
"""
else:
text += f"\n✅ Неактуальных подписок не найдено!\nВсе пользователи синхронизированы с панелью."
if stats['errors'] > 0:
text += f"\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации."
keyboard = [
[types.InlineKeyboardButton(text="🔄 Повторить очистку", callback_data="force_cleanup_orphaned")],
[types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
[types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def confirm_force_cleanup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = """
⚠️ <b>ВНИМАНИЕ! ОПАСНАЯ ОПЕРАЦИЯ!</b>
🗑️ <b>Принудительная очистка полностью удалит:</b>
• ВСЕ транзакции пользователей отсутствующих в панели
• ВСЕ реферальные доходы и связи
• ВСЕ использования промокодов
• ВСЕ подключенные серверы подписок
• ВСЕ балансы (сброс к нулю)
• ВСЕ HWID устройства в RemnaWave
• ВСЕ RemnaWave UUID и ссылки
⚡ <b>Это действие НЕОБРАТИМО!</b>
Используйте только если:
• Обычная синхронизация не помогает
• Нужно полностью очистить "мусорные" данные
• После массового удаления пользователей из панели
❓ <b>Вы действительно хотите продолжить?</b>
"""
keyboard = [
[types.InlineKeyboardButton(text="🗑️ ДА, ОЧИСТИТЬ ВСЕ", callback_data="force_cleanup_orphaned")],
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rw_sync")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@@ -1347,37 +1670,124 @@ async def sync_users(
):
sync_type = callback.data.split('_')[-2] + "_" + callback.data.split('_')[-1]
progress_text = "🔄 Выполняется синхронизация...\n\n"
if sync_type == "all_users":
progress_text += "📋 Тип: Полная синхронизация\n"
progress_text += "• Создание новых пользователей\n"
progress_text += "• Обновление существующих\n"
progress_text += "• Удаление неактуальных подписок\n"
elif sync_type == "new_users":
progress_text += "📋 Тип: Только новые пользователи\n"
progress_text += "• Создание пользователей из панели\n"
elif sync_type == "update_data":
progress_text += "📋 Тип: Обновление данных\n"
progress_text += "• Обновление информации о трафике\n"
progress_text += "• Синхронизация подписок\n"
progress_text += "\n⏳ Пожалуйста, подождите..."
await callback.message.edit_text(
"🔄 Выполняется синхронизация...\n\nПожалуйста, подождите.",
progress_text,
reply_markup=None
)
remnawave_service = RemnaWaveService()
if sync_type in ["all_users", "new_users", "update_data"]:
sync_map = {
"all_users": "all",
"new_users": "new_only",
"update_data": "update_only"
}
stats = await remnawave_service.sync_users_from_panel(db, sync_map[sync_type])
sync_map = {
"all_users": "all",
"new_users": "new_only",
"update_data": "update_only"
}
stats = await remnawave_service.sync_users_from_panel(db, sync_map.get(sync_type, "all"))
total_operations = stats['created'] + stats['updated'] + stats.get('deleted', 0)
success_operations = stats['created'] + stats['updated'] + stats.get('deleted', 0)
if stats['errors'] == 0:
status_emoji = ""
status_text = "успешно завершена"
elif stats['errors'] < total_operations:
status_emoji = "⚠️"
status_text = "завершена с предупреждениями"
else:
stats = {"created": 0, "updated": 0, "errors": 0}
status_emoji = ""
status_text = "завершена с ошибками"
text = f"""
<b>Синхронизация завершена</b>
{status_emoji} <b>Синхронизация {status_text}</b>
📊 <b>Результат:</b>
- Создано: {stats['created']}
- Обновлено: {stats['updated']}
- Ошибок: {stats['errors']}
"""
if sync_type == "all_users":
text += f"• 🆕 Создано: {stats['created']}\n"
text += f"• 🔄 Обновлено: {stats['updated']}\n"
if 'deleted' in stats:
text += f"• 🗑️ Удалено: {stats['deleted']}\n"
text += f"• ❌ Ошибок: {stats['errors']}\n"
elif sync_type == "new_users":
text += f"• 🆕 Создано: {stats['created']}\n"
text += f"• ❌ Ошибок: {stats['errors']}\n"
if stats['created'] == 0 and stats['errors'] == 0:
text += "\n💡 Новых пользователей не найдено"
elif sync_type == "update_data":
text += f"• 🔄 Обновлено: {stats['updated']}\n"
text += f"• ❌ Ошибок: {stats['errors']}\n"
if stats['updated'] == 0 and stats['errors'] == 0:
text += "\n💡 Все данные актуальны"
if stats['errors'] > 0:
text += f"\n⚠️ <b>Внимание:</b>\n"
text += f"Некоторые операции завершились с ошибками.\n"
text += f"Проверьте логи для получения подробной информации."
if sync_type == "all_users" and 'deleted' in stats and stats['deleted'] > 0:
text += f"\n🗑️ <b>Удаленные подписки:</b>\n"
text += f"Деактивированы подписки пользователей,\n"
text += f"которые отсутствуют в панели RemnaWave."
text += f"\n\n💡 <b>Рекомендации:</b>\n"
if sync_type == "all_users":
text += "• Полная синхронизация выполнена\n"
text += "• Рекомендуется запускать раз в день\n"
elif sync_type == "new_users":
text += "• Синхронизация новых пользователей\n"
text += "• Используйте при массовом добавлении\n"
elif sync_type == "update_data":
text += "• Обновление данных о трафике\n"
text += "• Запускайте для актуализации статистики\n"
keyboard = []
if stats['errors'] > 0:
keyboard.append([
types.InlineKeyboardButton(
text="🔄 Повторить синхронизацию",
callback_data=callback.data
)
])
if sync_type != "all_users":
keyboard.append([
types.InlineKeyboardButton(
text="🔄 Полная синхронизация",
callback_data="sync_all_users"
)
])
keyboard.extend([
[
types.InlineKeyboardButton(text="📊 Статистика системы", callback_data="admin_rw_system"),
types.InlineKeyboardButton(text="🌐 Ноды", callback_data="admin_rw_nodes")
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
])
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@@ -1435,6 +1845,11 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes")
dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync")
dp.callback_query.register(sync_users, F.data.startswith("sync_"))
dp.callback_query.register(show_sync_recommendations, F.data == "sync_recommendations")
dp.callback_query.register(validate_subscriptions, F.data == "sync_validate")
dp.callback_query.register(cleanup_subscriptions, F.data == "sync_cleanup")
dp.callback_query.register(confirm_force_cleanup, F.data == "confirm_force_cleanup")
dp.callback_query.register(force_cleanup_all_orphaned_users, F.data == "force_cleanup_orphaned")
dp.callback_query.register(show_squads_management, F.data == "admin_rw_squads")
dp.callback_query.register(show_squad_details, F.data.startswith("admin_squad_manage_"))
@@ -1474,4 +1889,4 @@ def register_handlers(dp: Dispatcher):
process_squad_name,
SquadCreateStates.waiting_for_name,
F.text
)
)
+815 -21
View File
@@ -274,7 +274,7 @@ async def show_user_subscription(
text += f"<b>Окончание:</b> {format_datetime(subscription.end_date)}\n"
text += f"<b>Трафик:</b> {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n"
text += f"<b>Устройства:</b> {subscription.device_limit}\n"
text += f"<b>Подключенных устройств:</b> {len(subscription.connected_devices) if subscription.connected_devices else 0}\n"
text += f"<b>Подключенных устройств:</b> {subscription.device_limit}\n"
if subscription.is_active:
days_left = (subscription.end_date - datetime.utcnow()).days
@@ -521,7 +521,14 @@ async def show_user_management(
user = profile["user"]
subscription = profile["subscription"]
status_text = "✅ Активен" if user.status == UserStatus.ACTIVE.value else "❌ Заблокирован"
if user.status == UserStatus.ACTIVE.value:
status_text = "✅ Активен"
elif user.status == UserStatus.BLOCKED.value:
status_text = "🚫 Заблокирован"
elif user.status == UserStatus.DELETED.value:
status_text = "🗑️ Удален"
else:
status_text = "❓ Неизвестно"
text = f"""
👤 <b>Управление пользователем</b>
@@ -558,7 +565,7 @@ async def show_user_management(
await callback.message.edit_text(
text,
reply_markup=get_user_management_keyboard(user.id, db_user.language)
reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language)
)
await callback.answer()
@@ -746,6 +753,712 @@ async def show_inactive_users(
)
await callback.answer()
@admin_required
@error_handler
async def confirm_user_unblock(
callback: types.CallbackQuery,
db_user: User
):
user_id = int(callback.data.split('_')[-1])
await callback.message.edit_text(
"✅ <b>Разблокировка пользователя</b>\n\n"
"Вы уверены, что хотите разблокировать этого пользователя?\n"
"Пользователь снова получит доступ к боту.",
reply_markup=get_confirmation_keyboard(
f"admin_user_unblock_confirm_{user_id}",
f"admin_user_manage_{user_id}",
db_user.language
)
)
await callback.answer()
@admin_required
@error_handler
async def unblock_user(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
user_service = UserService()
success = await user_service.unblock_user(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Пользователь разблокирован",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка разблокировки пользователя",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def show_user_statistics(
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
user = profile["user"]
subscription = profile["subscription"]
text = f"📊 <b>Статистика пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
text += f"<b>Основная информация:</b>\n"
text += f"• Дней с регистрации: {profile['registration_days']}\n"
text += f"• Баланс: {settings.format_price(user.balance_kopeks)}\n"
text += f"• Транзакций: {profile['transactions_count']}\n"
text += f"• Язык: {user.language}\n\n"
text += f"<b>Подписка:</b>\n"
if subscription:
sub_status = "✅ Активна" if subscription.is_active else "❌ Неактивна"
sub_type = " (триал)" if subscription.is_trial else " (платная)"
text += f"• Статус: {sub_status}{sub_type}\n"
text += f"• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n"
text += f"• Устройства: {subscription.device_limit}\n"
text += f"• Стран: {len(subscription.connected_squads)}\n"
else:
text += f"• Отсутствует\n"
text += f"\n<b>Реферальная программа:</b>\n"
if user.referred_by_id:
text += f"• Пришел по рефералке\n"
else:
text += f"• Прямая регистрация\n"
text += f"• Реферальный код: <code>{user.referral_code}</code>\n"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def extend_user_subscription(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(extending_user_id=user_id)
await callback.message.edit_text(
"⏰ <b>Продление подписки</b>\n\n"
"Введите количество дней для продления:\n"
"• Например: 30, 7, 90\n"
"• Максимум: 365 дней\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="7 дней", callback_data=f"admin_sub_extend_days_{user_id}_7"),
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_extend_days_{user_id}_30")
],
[
types.InlineKeyboardButton(text="90 дней", callback_data=f"admin_sub_extend_days_{user_id}_90"),
types.InlineKeyboardButton(text="180 дней", callback_data=f"admin_sub_extend_days_{user_id}_180")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
]
])
)
await state.set_state(AdminStates.extending_subscription)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_extension_days(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
days = int(parts[-1])
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
await callback.message.edit_text(
f"✅ Подписка пользователя продлена на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка продления подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_extension_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("extending_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
days = int(message.text.strip())
if days <= 0 or days > 365:
await message.answer("❌ Количество дней должно быть от 1 до 365")
return
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
await message.answer(
f"✅ Подписка пользователя продлена на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка продления подписки")
except ValueError:
await message.answer("❌ Введите корректное число дней")
return
await state.clear()
@admin_required
@error_handler
async def add_subscription_traffic(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(traffic_user_id=user_id)
await callback.message.edit_text(
"📊 <b>Добавление трафика</b>\n\n"
"Введите количество ГБ для добавления:\n"
"• Например: 50, 100, 500\n"
"• Максимум: 10000 ГБ\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="50 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_50"),
types.InlineKeyboardButton(text="100 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_100")
],
[
types.InlineKeyboardButton(text="500 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_500"),
types.InlineKeyboardButton(text="1000 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_1000")
],
[
types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_sub_traffic_add_{user_id}_0"),
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
]
])
)
await state.set_state(AdminStates.adding_traffic)
await callback.answer()
@admin_required
@error_handler
async def process_traffic_addition_button(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
gb = int(parts[-1])
success = await _add_subscription_traffic(db, user_id, gb, db_user.id)
if success:
traffic_text = "♾️ безлимитный" if gb == 0 else f"{gb} ГБ"
await callback.message.edit_text(
f"✅ К подписке пользователя добавлен трафик: {traffic_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка добавления трафика",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_traffic_addition_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("traffic_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
gb = int(message.text.strip())
if gb < 0 or gb > 10000:
await message.answer("❌ Количество ГБ должно быть от 0 до 10000 (0 = безлимит)")
return
success = await _add_subscription_traffic(db, user_id, gb, db_user.id)
if success:
traffic_text = "♾️ безлимитный" if gb == 0 else f"{gb} ГБ"
await message.answer(
f"✅ К подписке пользователя добавлен трафик: {traffic_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка добавления трафика")
except ValueError:
await message.answer("❌ Введите корректное число ГБ")
return
await state.clear()
@admin_required
@error_handler
async def deactivate_user_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
await callback.message.edit_text(
"🚫 <b>Деактивация подписки</b>\n\n"
"Вы уверены, что хотите деактивировать подписку этого пользователя?\n"
"Пользователь потеряет доступ к сервису.",
reply_markup=get_confirmation_keyboard(
f"admin_sub_deactivate_confirm_{user_id}",
f"admin_user_subscription_{user_id}",
db_user.language
)
)
await callback.answer()
@admin_required
@error_handler
async def confirm_subscription_deactivation(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
success = await _deactivate_user_subscription(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Подписка пользователя деактивирована",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка деактивации подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def activate_user_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
success = await _activate_user_subscription(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Подписка пользователя активирована",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка активации подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def grant_trial_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
success = await _grant_trial_subscription(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Пользователю выдан триальный период",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка выдачи триального периода",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def grant_paid_subscription(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(granting_user_id=user_id)
await callback.message.edit_text(
"💎 <b>Выдача подписки</b>\n\n"
"Введите количество дней подписки:\n"
"• Например: 30, 90, 180, 365\n"
"• Максимум: 730 дней\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_grant_days_{user_id}_30"),
types.InlineKeyboardButton(text="90 дней", callback_data=f"admin_sub_grant_days_{user_id}_90")
],
[
types.InlineKeyboardButton(text="180 дней", callback_data=f"admin_sub_grant_days_{user_id}_180"),
types.InlineKeyboardButton(text="365 дней", callback_data=f"admin_sub_grant_days_{user_id}_365")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
]
])
)
await state.set_state(AdminStates.granting_subscription)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_grant_days(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
days = int(parts[-1])
success = await _grant_paid_subscription(db, user_id, days, db_user.id)
if success:
await callback.message.edit_text(
f"✅ Пользователю выдана подписка на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка выдачи подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_grant_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("granting_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
days = int(message.text.strip())
if days <= 0 or days > 730:
await message.answer("❌ Количество дней должно быть от 1 до 730")
return
success = await _grant_paid_subscription(db, user_id, days, db_user.id)
if success:
await message.answer(
f"✅ Пользователю выдана подписка на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка выдачи подписки")
except ValueError:
await message.answer("❌ Введите корректное число дней")
return
await state.clear()
async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
await extend_subscription(db, subscription, days)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
return True
except Exception as e:
logger.error(f"Ошибка продления подписки: {e}")
return False
async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, add_subscription_traffic
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
if gb == 0:
subscription.traffic_limit_gb = 0
await db.commit()
else:
await add_subscription_traffic(db, subscription, gb)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
traffic_text = "безлимитный" if gb == 0 else f"{gb} ГБ"
logger.info(f"Админ {admin_id} добавил трафик {traffic_text} пользователю {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка добавления трафика: {e}")
return False
async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, deactivate_subscription
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
await deactivate_subscription(db, subscription)
user = await get_user_by_id(db, user_id)
if user and user.remnawave_uuid:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"Админ {admin_id} деактивировал подписку пользователя {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка деактивации подписки: {e}")
return False
async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id
from app.services.subscription_service import SubscriptionService
from app.database.models import SubscriptionStatus
from datetime import datetime
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
subscription.status = SubscriptionStatus.ACTIVE.value
if subscription.end_date <= datetime.utcnow():
subscription.end_date = datetime.utcnow() + timedelta(days=1)
await db.commit()
await db.refresh(subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} активировал подписку пользователя {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка активации подписки: {e}")
return False
async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, create_trial_subscription
from app.services.subscription_service import SubscriptionService
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
subscription = await create_trial_subscription(db, user_id)
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} выдал триальную подписку пользователю {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка выдачи триальной подписки: {e}")
return False
async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
from app.services.subscription_service import SubscriptionService
from app.config import settings
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=days,
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=settings.DEFAULT_DEVICE_LIMIT,
connected_squads=[settings.TRIAL_SQUAD_UUID] if settings.TRIAL_SQUAD_UUID else []
)
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} выдал платную подписку на {days} дней пользователю {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка выдачи платной подписки: {e}")
return False
@admin_required
@error_handler
@@ -787,23 +1500,48 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_user_subscription,
F.data.startswith("admin_user_sub_")
F.data.startswith("admin_user_subscription_")
)
dp.callback_query.register(
show_user_transactions,
F.data.startswith("admin_user_trans_")
F.data.startswith("admin_user_transactions_")
)
dp.callback_query.register(
confirm_user_delete,
F.data.startswith("admin_user_delete_")
show_user_statistics,
F.data.startswith("admin_user_statistics_")
)
dp.callback_query.register(
block_user,
F.data.startswith("admin_user_block_confirm_")
)
dp.callback_query.register(
delete_user_account,
F.data.startswith("admin_user_delete_confirm_")
)
dp.callback_query.register(
confirm_user_block,
F.data.startswith("admin_user_block_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
unblock_user,
F.data.startswith("admin_user_unblock_confirm_")
)
dp.callback_query.register(
confirm_user_unblock,
F.data.startswith("admin_user_unblock_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
confirm_user_delete,
F.data.startswith("admin_user_delete_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
handle_users_list_pagination_fixed,
@@ -835,16 +1573,6 @@ def register_handlers(dp: Dispatcher):
AdminStates.editing_user_balance
)
dp.callback_query.register(
confirm_user_block,
F.data.startswith("admin_user_block_")
)
dp.callback_query.register(
block_user,
F.data.startswith("admin_user_block_confirm_")
)
dp.callback_query.register(
show_inactive_users,
F.data == "admin_users_inactive"
@@ -853,4 +1581,70 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
cleanup_inactive_users,
F.data == "admin_cleanup_inactive"
)
)
dp.callback_query.register(
extend_user_subscription,
F.data.startswith("admin_sub_extend_") & ~F.data.contains("days") & ~F.data.contains("confirm")
)
dp.callback_query.register(
process_subscription_extension_days,
F.data.startswith("admin_sub_extend_days_")
)
dp.message.register(
process_subscription_extension_text,
AdminStates.extending_subscription
)
dp.callback_query.register(
add_subscription_traffic,
F.data.startswith("admin_sub_traffic_") & ~F.data.contains("add")
)
dp.callback_query.register(
process_traffic_addition_button,
F.data.startswith("admin_sub_traffic_add_")
)
dp.message.register(
process_traffic_addition_text,
AdminStates.adding_traffic
)
dp.callback_query.register(
deactivate_user_subscription,
F.data.startswith("admin_sub_deactivate_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
confirm_subscription_deactivation,
F.data.startswith("admin_sub_deactivate_confirm_")
)
dp.callback_query.register(
activate_user_subscription,
F.data.startswith("admin_sub_activate_")
)
dp.callback_query.register(
grant_trial_subscription,
F.data.startswith("admin_sub_grant_trial_")
)
dp.callback_query.register(
grant_paid_subscription,
F.data.startswith("admin_sub_grant_") & ~F.data.contains("trial") & ~F.data.contains("days")
)
dp.callback_query.register(
process_subscription_grant_days,
F.data.startswith("admin_sub_grant_days_")
)
dp.message.register(
process_subscription_grant_text,
AdminStates.granting_subscription
)
+167 -105
View File
@@ -55,15 +55,46 @@ async def show_balance_history(
offset = (page - 1) * TRANSACTIONS_PER_PAGE
transactions = await get_user_transactions(
raw_transactions = await get_user_transactions(
db, db_user.id,
limit=TRANSACTIONS_PER_PAGE,
limit=TRANSACTIONS_PER_PAGE * 3,
offset=offset
)
total_count = await get_user_transactions_count(db, db_user.id)
seen_transactions = set()
unique_transactions = []
if not transactions:
for transaction in raw_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_transactions:
seen_transactions.add(transaction_key)
unique_transactions.append(transaction)
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
break
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
seen_all = set()
total_unique = 0
for transaction in all_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_all:
seen_all.add(transaction_key)
total_unique += 1
if not unique_transactions:
await callback.message.edit_text(
"📊 История операций пуста",
reply_markup=get_back_keyboard(db_user.language)
@@ -73,7 +104,7 @@ async def show_balance_history(
text = "📊 <b>История операций</b>\n\n"
for transaction in transactions:
for transaction in unique_transactions:
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
@@ -82,7 +113,7 @@ async def show_balance_history(
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
keyboard = []
total_pages = (total_count + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
if total_pages > 1:
pagination_row = get_pagination_keyboard(
@@ -96,7 +127,8 @@ async def show_balance_history(
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@@ -112,16 +144,43 @@ async def handle_balance_history_pagination(
@error_handler
async def start_balance_topup(
async def show_payment_methods(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
payment_text = """
💳 <b>Способы пополнения баланса</b>
Выберите удобный для вас способ оплаты:
<b>Telegram Stars</b> - быстро и удобно
💎 <b>Банковская карта</b> - через Tribute
🛠 <b>Через поддержку</b> - другие способы
Выберите способ пополнения:
"""
await callback.message.edit_text(
payment_text,
reply_markup=get_payment_methods_keyboard(0, db_user.language),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def start_stars_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""Начать пополнение - только для Telegram Stars"""
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Пополнение временно недоступно", show_alert=True)
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
return
await callback.message.edit_text(
@@ -130,96 +189,15 @@ async def start_balance_topup(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="stars")
await callback.answer()
@error_handler
async def process_topup_amount(
message: types.Message,
db_user: User,
state: FSMContext
):
"""Обработка введенной суммы - только для Telegram Stars"""
texts = get_texts(db_user.language)
try:
amount_rubles = float(message.text.replace(',', '.'))
if amount_rubles < 1:
await message.answer("❌ Минимальная сумма пополнения: 1 ₽")
return
if amount_rubles > 50000:
await message.answer("❌ Максимальная сумма пополнения: 50,000 ₽")
return
amount_kopeks = int(amount_rubles * 100)
await state.update_data(amount_kopeks=amount_kopeks)
payment_text = texts.TOP_UP_METHODS.format(
amount=texts.format_price(amount_kopeks)
)
await message.answer(
payment_text,
reply_markup=get_payment_methods_keyboard(amount_kopeks, db_user.language)
)
except ValueError:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
@error_handler
async def process_stars_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Оплата Stars временно недоступна", show_alert=True)
return
amount_kopeks = int(callback.data.split('_')[-1])
try:
payment_service = PaymentService(callback.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"Сумма: {texts.format_price(amount_kopeks)}\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
@error_handler
async def process_tribute_quick_payment(
async def start_tribute_payment(
callback: types.CallbackQuery,
db_user: User
):
"""Быстрое пополнение через Tribute - без выбора суммы"""
texts = get_texts(db_user.language)
if not settings.TRIBUTE_ENABLED:
@@ -242,17 +220,19 @@ async def process_tribute_quick_payment(
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
f"💳 <b>Пополнение банковской картой</b>\n\n"
f"• Введите любую сумму от 50 \n"
f"• Введите любую сумму от 100\n"
f"• Безопасная оплата через Tribute\n"
f"• Мгновенное зачисление на баланс\n"
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
f"Нажмите кнопку для перехода к оплате:",
reply_markup=keyboard
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
@@ -281,6 +261,11 @@ async def request_support_topup(
Способ оплаты
Время обработки: 1-24 часа
<b>Доступные способы:</b>
Криптовалюта
Переводы между банками
Другие платежные системы
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
@@ -288,16 +273,93 @@ async def request_support_topup(
text="💬 Написать в поддержку",
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
support_text,
reply_markup=keyboard
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def process_topup_amount(
message: types.Message,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
try:
amount_rubles = float(message.text.replace(',', '.'))
if amount_rubles < 1:
await message.answer("❌ Минимальная сумма пополнения: 1 ₽")
return
if amount_rubles > 50000:
await message.answer("❌ Максимальная сумма пополнения: 50,000 ₽")
return
amount_kopeks = int(amount_rubles * 100)
data = await state.get_data()
payment_method = data.get("payment_method", "stars")
if payment_method == "stars":
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
else:
await message.answer("❌ Неизвестный способ оплаты")
except ValueError:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
@error_handler
async def process_stars_payment_amount(
message: types.Message,
db_user: User,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await message.answer("❌ Оплата Stars временно недоступна")
return
try:
payment_service = PaymentService(message.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"Сумма: {texts.format_price(amount_kopeks)}\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer("❌ Ошибка создания платежа")
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
@@ -316,26 +378,26 @@ def register_handlers(dp: Dispatcher):
)
dp.callback_query.register(
start_balance_topup,
show_payment_methods,
F.data == "balance_topup"
)
dp.callback_query.register(
process_stars_payment,
F.data.startswith("pay_stars_")
start_stars_payment,
F.data == "topup_stars"
)
dp.callback_query.register(
process_tribute_quick_payment,
F.data == "tribute_quick_pay"
start_tribute_payment,
F.data == "topup_tribute"
)
dp.callback_query.register(
request_support_topup,
F.data == "balance_support"
F.data == "topup_support"
)
dp.message.register(
process_topup_amount,
BalanceStates.waiting_for_amount
)
)
+28 -9
View File
@@ -2,6 +2,7 @@ import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
@@ -108,15 +109,33 @@ async def handle_back_to_menu(
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return texts.SUBSCRIPTION_NONE
return "⌐ Отсутствует"
if user.subscription.is_trial:
return f"{texts.SUBSCRIPTION_TRIAL} (до {user.subscription.end_date.strftime('%d.%m.%Y')})"
elif user.subscription.is_active:
days_left = user.subscription.days_left
return f"{texts.SUBSCRIPTION_ACTIVE} ({days_left} дн.)"
else:
return texts.SUBSCRIPTION_EXPIRED
subscription = user.subscription
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
days_left = (subscription.end_date - current_time).days
if subscription.is_trial:
if days_left > 1:
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left == 1:
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
else:
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
else:
if days_left > 7:
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left > 1:
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
elif days_left == 1:
return f"💎 Активна\n⚠️ истекает завтра!"
else:
return f"💎 Активна\n⚠️ истекает сегодня!"
def register_handlers(dp: Dispatcher):
@@ -129,4 +148,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_service_rules,
F.data == "menu_rules"
)
)
+162 -52
View File
@@ -9,6 +9,7 @@ from app.states import RegistrationStates
from app.database.crud.user import (
get_user_by_telegram_id, create_user, get_user_by_referral_code
)
from app.database.models import UserStatus
from app.keyboards.inline import (
get_rules_keyboard, get_main_menu_keyboard
)
@@ -19,7 +20,7 @@ from app.utils.user_utils import generate_unique_referral_code
logger = logging.getLogger(__name__)
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession):
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
referral_code = None
@@ -31,10 +32,10 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession)
if referral_code:
await state.set_data({'referral_code': referral_code})
user = await get_user_by_telegram_id(db, message.from_user.id)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
if user:
logger.info(f"Пользователь найден: {user.telegram_id}")
if user and user.status != UserStatus.DELETED.value:
logger.info(f"Активный пользователь найден: {user.telegram_id}")
texts = get_texts(user.language)
if referral_code and not user.referred_by_id:
@@ -60,26 +61,82 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession)
subscription_is_active=subscription_is_active
)
)
await state.clear()
return
if user and user.status == UserStatus.DELETED.value:
logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию")
try:
from app.services.user_service import UserService
from app.database.models import (
Subscription, Transaction, PromoCodeUse,
ReferralEarning, SubscriptionServer
)
from sqlalchemy import delete
if user.subscription:
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
)
)
logger.info(f"🗑️ Удалены записи SubscriptionServer")
if user.subscription:
await db.delete(user.subscription)
logger.info(f"🗑️ Удалена подписка пользователя")
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
)
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
)
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
)
await db.execute(
delete(Transaction).where(Transaction.user_id == user.id)
)
user.status = UserStatus.ACTIVE.value
user.balance_kopeks = 0
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.referred_by_id = None
from app.utils.user_utils import generate_unique_referral_code
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
await db.commit()
logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению")
except Exception as e:
logger.error(f"❌ Ошибка подготовки к восстановлению: {e}")
await db.rollback()
else:
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
language = 'ru'
texts = get_texts(language)
data = await state.get_data() or {}
data['language'] = language
await state.set_data(data)
logger.info(f"💾 Установлен русский язык по умолчанию")
await message.answer(
texts.RULES_TEXT,
reply_markup=get_rules_keyboard(language)
)
logger.info(f"📋 Правила отправлены")
await state.set_state(RegistrationStates.waiting_for_rules_accept)
current_state = await state.get_state()
logger.info(f"📊 Установлено состояние: {current_state}")
language = 'ru'
texts = get_texts(language)
data = await state.get_data() or {}
data['language'] = language
await state.set_data(data)
logger.info(f"💾 Установлен русский язык по умолчанию")
await message.answer(
texts.RULES_TEXT,
reply_markup=get_rules_keyboard(language)
)
logger.info(f"📋 Правила отправлены")
await state.set_state(RegistrationStates.waiting_for_rules_accept)
current_state = await state.get_state()
logger.info(f"📊 Установлено состояние: {current_state}")
async def process_rules_accept(
@@ -125,7 +182,7 @@ async def process_rules_accept(
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Референс найден: {referrer.id}")
logger.info(f"✅ Реферер найден: {referrer.id}")
await complete_registration_from_callback(callback, state, db)
else:
@@ -237,8 +294,9 @@ async def complete_registration_from_callback(
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
if existing_user:
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже существует! Показываем главное меню.")
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
texts = get_texts(existing_user.language)
has_active_subscription = existing_user.subscription is not None
@@ -282,18 +340,43 @@ async def complete_registration_from_callback(
if referrer:
referrer_id = referrer.id
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
user = await create_user(
db=db,
telegram_id=callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code
)
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}")
existing_user.username = callback.from_user.username
existing_user.first_name = callback.from_user.first_name
existing_user.last_name = callback.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
user = existing_user
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
else:
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
user = await create_user(
db=db,
telegram_id=callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code
)
if referrer_id:
try:
@@ -355,7 +438,7 @@ async def complete_registration_from_callback(
except Exception as final_error:
logger.error(f"❌ Критическая ошибка при отправке простого сообщения: {final_error}")
logger.info(f"Зарегистрирован новый пользователь: {user_telegram_id}")
logger.info(f"Регистрация завершена для пользователя: {user_telegram_id}")
async def complete_registration(
@@ -366,6 +449,8 @@ async def complete_registration(
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
data = await state.get_data()
language = data.get('language', 'ru')
texts = get_texts(language)
@@ -376,18 +461,43 @@ async def complete_registration(
if referrer:
referrer_id = referrer.id
referral_code = await generate_unique_referral_code(db, message.from_user.id)
user = await create_user(
db=db,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code
)
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}")
existing_user.username = message.from_user.username
existing_user.first_name = message.from_user.first_name
existing_user.last_name = message.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
user = existing_user
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
else:
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
referral_code = await generate_unique_referral_code(db, message.from_user.id)
user = await create_user(
db=db,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code
)
if referrer_id:
try:
@@ -449,7 +559,7 @@ async def complete_registration(
except:
pass
logger.info(f"Зарегистрирован новый пользователь: {user_telegram_id}")
logger.info(f"Регистрация завершена для пользователя: {user_telegram_id}")
def _get_subscription_status(user, texts):
@@ -504,4 +614,4 @@ def register_handlers(dp: Dispatcher):
)
logger.info("✅ Зарегистрирован process_referral_code_input")
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
from app.database.models import User, Subscription
from app.database.crud.user import get_user_by_id, subtract_user_balance
from app.database.crud.subscription import get_expiring_subscriptions, extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
from app.keyboards.inline import get_autopay_notification_keyboard, get_subscription_expiring_keyboard
from app.localization.texts import get_texts
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
async def send_subscription_expiring_notification(
bot: Bot,
db: AsyncSession,
subscription: Subscription,
days_left: int
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return False
texts = get_texts(user.language)
if subscription.is_trial:
text = texts.TRIAL_ENDING_SOON.format(
price=texts.format_price(30000)
)
else:
autopay_status = texts.AUTOPAY_ENABLED_TEXT if subscription.autopay_enabled else texts.AUTOPAY_DISABLED_TEXT
if subscription.autopay_enabled:
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
else:
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
text = texts.SUBSCRIPTION_EXPIRING_PAID.format(
days=days_left,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
autopay_status=autopay_status,
action_text=action_text
)
keyboard = get_subscription_expiring_keyboard(subscription.id, user.language)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление об истечении подписки пользователю {user.telegram_id}")
return True
except TelegramBadRequest as e:
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления об истечении подписки: {e}")
return False
async def send_autopay_failed_notification(
bot: Bot,
db: AsyncSession,
subscription: Subscription,
required_amount: int
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return False
texts = get_texts(user.language)
text = texts.AUTOPAY_FAILED.format(
balance=texts.format_price(user.balance_kopeks),
required=texts.format_price(required_amount)
)
keyboard = get_autopay_notification_keyboard(subscription.id, user.language)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление о неудачном автоплатеже пользователю {user.telegram_id}")
return True
except TelegramBadRequest as e:
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления о неудачном автоплатеже: {e}")
return False
async def process_autopayment(
bot: Bot,
db: AsyncSession,
subscription: Subscription
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.error(f"Пользователь {subscription.user_id} не найден для автоплатежа")
return False
subscription_service = SubscriptionService()
renewal_cost = await subscription_service.calculate_renewal_price(
subscription, 30, db
)
if user.balance_kopeks < renewal_cost:
logger.warning(f"Недостаточно средств для автоплатежа у пользователя {user.telegram_id}")
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
return False
success = await subtract_user_balance(
db, user, renewal_cost,
f"Автопродление подписки на 30 дней"
)
if not success:
logger.error(f"Ошибка списания средств для автоплатежа у пользователя {user.telegram_id}")
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
return False
await extend_subscription(db, subscription, 30)
await subscription_service.update_remnawave_user(db, subscription)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=renewal_cost,
description="Автопродление подписки на 30 дней"
)
texts = get_texts(user.language)
success_text = texts.AUTOPAY_SUCCESS.format(
days=30,
amount=texts.format_price(renewal_cost),
new_end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M")
)
await bot.send_message(
chat_id=user.telegram_id,
text=success_text,
parse_mode="HTML"
)
logger.info(f"✅ Автоплатеж успешно выполнен для пользователя {user.telegram_id}")
return True
except Exception as e:
logger.error(f"❌ Ошибка обработки автоплатежа: {e}")
return False
+69 -21
View File
@@ -172,24 +172,38 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_user_management_keyboard(user_id: int, language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup:
keyboard = [
[
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_sub_{user_id}")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_stats_{user_id}"),
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_trans_{user_id}")
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}"),
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}")
]
]
if user_status == "active":
keyboard.append([
InlineKeyboardButton(text="🚫 Заблокировать", callback_data=f"admin_user_block_{user_id}"),
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
]
])
elif user_status == "blocked":
keyboard.append([
InlineKeyboardButton(text="✅ Разблокировать", callback_data=f"admin_user_unblock_{user_id}"),
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
])
elif user_status == "deleted":
keyboard.append([
InlineKeyboardButton(text="❌ Пользователь удален", callback_data="noop")
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_confirmation_keyboard(
@@ -293,8 +307,8 @@ def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today")
],
[
InlineKeyboardButton(text="💤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
InlineKeyboardButton(text="💤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
InlineKeyboardButton(text="👤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
InlineKeyboardButton(text="👤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
],
[
InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"),
@@ -339,18 +353,52 @@ def get_broadcast_history_keyboard(page: int, total_pages: int, language: str =
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
keyboard = [
[InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
[InlineKeyboardButton(text="🆕 Только новые", callback_data="sync_new_users")],
[InlineKeyboardButton(text="📈 Обновить данные", callback_data="sync_update_data")],
[
InlineKeyboardButton(text="🔄 Синхронизировать всех", callback_data="sync_all_users"),
InlineKeyboardButton(text=" Только новых", callback_data="sync_new_users")
InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate"),
InlineKeyboardButton(text="🧹 Очистка", callback_data="sync_cleanup")
],
[InlineKeyboardButton(text="💡 Рекомендации", callback_data="sync_recommendations")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_confirmation_keyboard(sync_type: str, language: str = "ru") -> InlineKeyboardMarkup:
keyboard = [
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"confirm_{sync_type}")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rw_sync")]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
keyboard = []
if has_errors:
keyboard.append([
InlineKeyboardButton(text="🔄 Повторить", callback_data=f"sync_{sync_type}")
])
if sync_type != "all_users":
keyboard.append([
InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")
])
keyboard.extend([
[
InlineKeyboardButton(text="📊 Обновить данные", callback_data="sync_update_data")
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_rw_system"),
InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")
]
[InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="admin_remnawave")]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_period_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
@@ -526,4 +574,4 @@ def get_admin_pagination_keyboard(
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+320 -53
View File
@@ -97,44 +97,44 @@ def get_subscription_keyboard(
is_trial: bool = False,
subscription=None
) -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
keyboard = []
if has_subscription:
if subscription and subscription.subscription_url:
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
button_text = "🚀 Открыть в мини-приложении"
elif connect_mode == "miniapp_custom":
button_text = "🚀 Открыть приложение"
else:
button_text = "🔗 Подключиться"
keyboard.append([
InlineKeyboardButton(text=button_text, callback_data="subscription_connect")
])
if not is_trial and subscription and subscription.days_left <= 3:
keyboard.append([
InlineKeyboardButton(text="⏰ Продлить", callback_data="subscription_extend")
])
if not is_trial:
keyboard.append([
InlineKeyboardButton(text="💳 Автоплатеж", callback_data="subscription_autopay")
])
if is_trial:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
])
else:
row1 = []
row2 = []
row3 = []
row4 = []
row1.append(InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries"))
if subscription and subscription.traffic_limit_gb > 0:
row1.append(InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic"))
row2.append(InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices"))
if subscription and subscription.days_left <= 3:
row2.append(InlineKeyboardButton(text="⏰ Продлить", callback_data="subscription_extend"))
row3.append(InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic"))
row3.append(InlineKeyboardButton(text="💳 Автоплатеж", callback_data="subscription_autopay"))
row4.append(InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices"))
if row1:
keyboard.append(row1)
if row2:
keyboard.append(row2)
if row3:
keyboard.append(row3)
if row4:
keyboard.append(row4)
keyboard.append([
InlineKeyboardButton(text="⚙️ Настройки подписки", callback_data="subscription_settings")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
@@ -142,6 +142,36 @@ def get_subscription_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_subscription_settings_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
keyboard = [
[
InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries")
],
[
InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices")
],
[
InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices")
]
]
if settings.is_traffic_selectable():
keyboard.insert(1, [
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
])
keyboard.insert(2, [
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -179,6 +209,11 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
keyboard = []
@@ -189,7 +224,7 @@ def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
(50, texts.TRAFFIC_50GB),
(100, texts.TRAFFIC_100GB),
(250, texts.TRAFFIC_250GB),
(0, texts.TRAFFIC_UNLIMITED) # 0 = безлимит
(0, texts.TRAFFIC_UNLIMITED)
]
for gb, text in packages:
@@ -278,26 +313,16 @@ def get_subscription_confirm_keyboard(language: str = "ru") -> InlineKeyboardMar
def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
keyboard.append([
InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data="balance_history"),
InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup")
])
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(text="💳 Быстрое пополнение", callback_data="tribute_quick_pay")
])
keyboard.append([
InlineKeyboardButton(text=texts.BALANCE_SUPPORT_REQUEST, callback_data="balance_support")
])
# Назад
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
keyboard = [
[
InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data="balance_history"),
InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -308,16 +333,74 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([
InlineKeyboardButton(text=texts.TOP_UP_STARS, callback_data=f"pay_stars_{amount_kopeks}")
InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="topup_stars"
)
])
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="💳 Банковская карта",
callback_data="topup_tribute"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
InlineKeyboardButton(
text="🛠️ Через поддержку",
callback_data="topup_support"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_autopay_notification_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="💳 Пополнить баланс",
callback_data="balance_topup"
)
],
[
InlineKeyboardButton(
text="📱 Моя подписка",
callback_data="menu_subscription"
)
]
])
def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="⏰ Продлить подписку",
callback_data="subscription_extend"
)
],
[
InlineKeyboardButton(
text="💳 Пополнить баланс",
callback_data="balance_topup"
)
],
[
InlineKeyboardButton(
text="📱 Моя подписка",
callback_data="menu_subscription"
)
]
])
def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -466,6 +549,11 @@ def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
keyboard = []
@@ -515,6 +603,11 @@ def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> Inli
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
@@ -524,7 +617,7 @@ def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru")
)
],
[
InlineKeyboardButton(text=" Отмена", callback_data="menu_subscription")
InlineKeyboardButton(text=" Отмена", callback_data="menu_subscription")
]
])
@@ -534,7 +627,6 @@ def get_manage_countries_keyboard(
current_subscription_countries: List[str],
language: str = "ru"
) -> InlineKeyboardMarkup:
"""Клавиатура для управления странами подписки БЕЗ состояний FSM"""
texts = get_texts(language)
keyboard = []
@@ -592,3 +684,178 @@ def get_manage_countries_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_device_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
keyboard = [
[
InlineKeyboardButton(text="📱 iOS (iPhone/iPad)", callback_data="device_guide_ios"),
InlineKeyboardButton(text="🤖 Android", callback_data="device_guide_android")
],
[
InlineKeyboardButton(text="💻 Windows", callback_data="device_guide_windows"),
InlineKeyboardButton(text="🎯 macOS", callback_data="device_guide_mac")
],
[
InlineKeyboardButton(text="📺 Android TV", callback_data="device_guide_tv")
]
]
if settings.CONNECT_BUTTON_MODE == "guide":
keyboard.append([
InlineKeyboardButton(text="📋 Показать ссылку подписки", callback_data="open_subscription_link")
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_connection_guide_keyboard(
subscription_url: str,
app: dict,
language: str = "ru"
) -> InlineKeyboardMarkup:
from app.handlers.subscription import create_deep_link
keyboard = []
if 'installationStep' in app and 'buttons' in app['installationStep']:
app_buttons = []
for button in app['installationStep']['buttons']:
button_text = button['buttonText'].get(language, button['buttonText']['en'])
app_buttons.append(
InlineKeyboardButton(text=f"📥 {button_text}", url=button['buttonLink'])
)
if len(app_buttons) == 2:
keyboard.append(app_buttons)
app_buttons = []
if app_buttons:
keyboard.append(app_buttons)
keyboard.append([
InlineKeyboardButton(text="📋 Скопировать ссылку подписки", url=subscription_url)
])
keyboard.extend([
[
InlineKeyboardButton(text="📱 Выбрать другое устройство", callback_data="subscription_connect")
],
[
InlineKeyboardButton(text="⬅️ К подписке", callback_data="menu_subscription")
]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_app_selection_keyboard(
device_type: str,
apps: list,
language: str = "ru"
) -> InlineKeyboardMarkup:
keyboard = []
for app in apps:
app_name = app['name']
if app.get('isFeatured', False):
app_name = f"{app_name}"
keyboard.append([
InlineKeyboardButton(
text=app_name,
callback_data=f"app_{device_type}_{app['id']}"
)
])
keyboard.extend([
[
InlineKeyboardButton(text="📱 Выбрать другое устройство", callback_data="subscription_connect")
],
[
InlineKeyboardButton(text="⬅️ К подписке", callback_data="menu_subscription")
]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_specific_app_keyboard(
subscription_url: str,
app: dict,
device_type: str,
language: str = "ru"
) -> InlineKeyboardMarkup:
from app.handlers.subscription import create_deep_link
keyboard = []
if 'installationStep' in app and 'buttons' in app['installationStep']:
app_buttons = []
for button in app['installationStep']['buttons']:
button_text = button['buttonText'].get(language, button['buttonText']['en'])
app_buttons.append(
InlineKeyboardButton(text=f"📥 {button_text}", url=button['buttonLink'])
)
if len(app_buttons) == 2:
keyboard.append(app_buttons)
app_buttons = []
if app_buttons:
keyboard.append(app_buttons)
keyboard.append([
InlineKeyboardButton(text="📋 Скопировать ссылку подписки", url=subscription_url)
])
if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']:
for button in app['additionalAfterAddSubscriptionStep']['buttons']:
button_text = button['buttonText'].get(language, button['buttonText']['en'])
keyboard.append([
InlineKeyboardButton(text=button_text, url=button['buttonLink'])
])
keyboard.extend([
[
InlineKeyboardButton(text="📋 Другие приложения", callback_data=f"app_list_{device_type}")
],
[
InlineKeyboardButton(text="📱 Выбрать другое устройство", callback_data="subscription_connect")
],
[
InlineKeyboardButton(text="⬅️ К подписке", callback_data="menu_subscription")
]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
from app.localization.texts import get_texts
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=f"📅 30 дней - {texts.format_price(prices[30])}",
callback_data="extend_period_30"
)
],
[
InlineKeyboardButton(
text=f"📅 90 дней - {texts.format_price(prices[90])}",
callback_data="extend_period_90"
)
],
[
InlineKeyboardButton(
text=f"📅 180 дней - {texts.format_price(prices[180])}",
callback_data="extend_period_180"
)
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
])
+7 -7
View File
@@ -106,11 +106,11 @@ class RussianTexts(Texts):
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
MAIN_MENU = """
👤 <b>{user_name}</b>
MAIN_MENU = """👤 <b>{user_name}</b>
💰 <b>Баланс:</b> {balance}
📱 <b>Подписка:</b> {subscription_status}
Выберите действие:
"""
@@ -268,7 +268,7 @@ class RussianTexts(Texts):
TRIAL_ENDING_SOON = """
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
Ваша тестовая подписка истекает через несколько часов.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку!
@@ -283,7 +283,7 @@ class RussianTexts(Texts):
"""
SUBSCRIPTION_EXPIRING_PAID = """
<b>Подписка истекает через {days} дней!</b>
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {end_date}.
@@ -354,7 +354,7 @@ class RussianTexts(Texts):
ADMIN_MONITORING = "🔍 Мониторинг"
ADMIN_REFERRALS = "🤝 Рефералы"
ADMIN_RULES = "📋 Правила"
ADMIN_REMNAWAVE = "🖥️ RemnaWave"
ADMIN_REMNAWAVE = "🖥️ Remnawave"
ADMIN_STATISTICS = "📊 Статистика"
ACCESS_DENIED = "❌ Доступ запрещен"
@@ -485,4 +485,4 @@ async def refresh_rules_cache(language: str = "ru"):
def clear_rules_cache():
global _cached_rules
_cached_rules.clear()
print("✅ Кеш правил очищен")
print("✅ Кеш правил очищен")
+56 -6
View File
@@ -56,7 +56,7 @@ class AuthMiddleware(BaseMiddleware):
)
if is_registration_process:
logger.info(f"🔓 Пропускаем пользователя {user.id} в процессе регистрации")
logger.info(f"📝 Пропускаем пользователя {user.id} в процессе регистрации")
data['db'] = db
data['db_user'] = None
data['is_admin'] = False
@@ -64,24 +64,74 @@ class AuthMiddleware(BaseMiddleware):
else:
if isinstance(event, Message):
await event.answer(
"️ Для начала работы необходимо выполнить команду /start"
"️ Для начала работы необходимо выполнить команду /start"
)
elif isinstance(event, CallbackQuery):
await event.answer(
"️ Необходимо начать с команды /start",
"️ Необходимо начать с команды /start",
show_alert=True
)
logger.info(f"🚫 Заблокирован незарегистрированный пользователь {user.id}")
return
else:
from app.database.models import UserStatus
if db_user.status == UserStatus.BLOCKED.value:
if isinstance(event, Message):
await event.answer("🚫 Ваш аккаунт заблокирован администратором.")
elif isinstance(event, CallbackQuery):
await event.answer("🚫 Ваш аккаунт заблокирован администратором.", show_alert=True)
logger.info(f"🚫 Заблокированный пользователь {user.id} попытался использовать бота")
return
if db_user.status == UserStatus.DELETED.value:
state: FSMContext = data.get('state')
current_state = None
if state:
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_rules_accept,
RegistrationStates.waiting_for_referral_code
]
is_start_or_registration = (
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
or (isinstance(event, CallbackQuery) and current_state and
any(str(state) in str(current_state) for state in registration_states))
or (isinstance(event, CallbackQuery) and event.data and
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
)
if is_start_or_registration:
logger.info(f"🔄 Удаленный пользователь {user.id} начинает повторную регистрацию")
data['db'] = db
data['db_user'] = None
data['is_admin'] = False
return await handler(event, data)
else:
if isinstance(event, Message):
await event.answer(
"❌ Ваш аккаунт был удален.\n"
"🔄 Для повторной регистрации выполните команду /start"
)
elif isinstance(event, CallbackQuery):
await event.answer(
"❌ Ваш аккаунт был удален. Для повторной регистрации выполните /start",
show_alert=True
)
logger.info(f"❌ Удаленный пользователь {user.id} попытался использовать бота без /start")
return
from datetime import datetime
db_user.last_activity = datetime.utcnow()
await db.commit()
data['db'] = db
data['db_user'] = db_user
data['is_admin'] = settings.is_admin(user.id)
return await handler(event, data)
except Exception as e:
@@ -90,4 +140,4 @@ class AuthMiddleware(BaseMiddleware):
if hasattr(event, 'data'):
logger.error(f"Callback data: {event.data}")
await db.rollback()
raise
raise
+53
View File
@@ -0,0 +1,53 @@
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 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):
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:
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
except Exception as e:
logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}")
return await handler(event, data)
+150 -61
View File
@@ -22,6 +22,10 @@ from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
from app.localization.texts import get_texts
from app.external.remnawave_api import (
RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
)
logger = logging.getLogger(__name__)
@@ -32,7 +36,7 @@ class MonitoringService:
self.subscription_service = SubscriptionService()
self.payment_service = PaymentService()
self.bot = bot
self._notified_users: Set[str] = set() # Защита от дублирования уведомлений
self._notified_users: Set[str] = set()
async def start_monitoring(self):
if self.is_running:
@@ -60,12 +64,11 @@ class MonitoringService:
try:
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db) # Новый метод!
await self._check_trial_expiring_soon(db)
await self._process_autopayments(db)
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
# Очищаем кеш уведомлений каждые 24 часа
current_hour = datetime.utcnow().hour
if current_hour == 0:
self._notified_users.clear()
@@ -88,40 +91,85 @@ class MonitoringService:
break
async def _check_expired_subscriptions(self, db: AsyncSession):
"""Проверка истекших подписок"""
try:
expired_subscriptions = await get_expired_subscriptions(db)
for subscription in expired_subscriptions:
await deactivate_subscription(db, subscription)
from app.database.crud.subscription import expire_subscription
await expire_subscription(db, subscription)
user = await get_user_by_id(db, subscription.user_id)
if user and user.remnawave_uuid:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
# Отправляем уведомление об истечении
if user and self.bot:
await self._send_subscription_expired_notification(user)
logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и деактивирована")
logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и статус изменен на 'expired'")
if expired_subscriptions:
await self._log_monitoring_event(
db, "expired_subscriptions_processed",
f"Обработано {len(expired_subscriptions)} истекших подписок",
f"Обработано {len(expired_subscriptions)} истёкших подписок",
{"count": len(expired_subscriptions)}
)
except Exception as e:
logger.error(f"Ошибка проверки истекших подписок: {e}")
logger.error(f"Ошибка проверки истёкших подписок: {e}")
async def update_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
) -> Optional[RemnaWaveUser]:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user or not user.remnawave_uuid:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
current_time = datetime.utcnow()
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
await db.commit()
is_active = False
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
async with self.api as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
active_internal_squads=subscription.connected_squads
)
subscription.subscription_url = updated_user.subscription_url
await db.commit()
status_text = "активным" if is_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
async def _check_expiring_subscriptions(self, db: AsyncSession):
"""Проверка подписок, истекающих через 2-3 дня (только платные)"""
try:
warning_days = settings.get_autopay_warning_days()
for days in warning_days:
# Получаем только платные подписки
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
for subscription in expiring_subscriptions:
@@ -131,7 +179,7 @@ class MonitoringService:
notification_key = f"expiring_{user.telegram_id}_{days}d"
if notification_key in self._notified_users:
continue # Уже уведомляли сегодня
continue
if self.bot:
await self._send_subscription_expiring_notification(user, subscription, days)
@@ -150,9 +198,7 @@ class MonitoringService:
logger.error(f"Ошибка проверки истекающих подписок: {e}")
async def _check_trial_expiring_soon(self, db: AsyncSession):
"""Проверка тестовых подписок, истекающих через 2 часа"""
try:
# Получаем тестовые подписки, истекающие через 2 часа
threshold_time = datetime.utcnow() + timedelta(hours=2)
result = await db.execute(
@@ -176,7 +222,7 @@ class MonitoringService:
notification_key = f"trial_2h_{user.telegram_id}"
if notification_key in self._notified_users:
continue # Уже уведомляли
continue
if self.bot:
await self._send_trial_ending_notification(user, subscription)
@@ -195,8 +241,8 @@ class MonitoringService:
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
"""Получение платных подписок, истекающих через указанное количество дней"""
threshold_date = datetime.utcnow() + timedelta(days=days_before)
current_time = datetime.utcnow()
threshold_date = current_time + timedelta(days=days_before)
result = await db.execute(
select(Subscription)
@@ -204,18 +250,24 @@ class MonitoringService:
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == False, # Только платные
Subscription.end_date <= threshold_date,
Subscription.end_date > datetime.utcnow()
Subscription.is_trial == False,
Subscription.end_date > current_time,
Subscription.end_date <= threshold_date
)
)
)
return result.scalars().all()
logger.info(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
logger.info(f"📅 Текущее время: {current_time}")
logger.info(f"📅 Пороговая дата: {threshold_date}")
subscriptions = result.scalars().all()
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
return subscriptions
async def _process_autopayments(self, db: AsyncSession):
"""Обработка автоплатежей"""
try:
# Исправленный запрос с использованием индивидуальных настроек
current_time = datetime.utcnow()
result = await db.execute(
@@ -225,13 +277,12 @@ class MonitoringService:
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.autopay_enabled == True,
Subscription.is_trial == False # Автооплата только для платных
Subscription.is_trial == False
)
)
)
all_autopay_subscriptions = result.scalars().all()
# Фильтруем по времени с учетом индивидуальных настроек
autopay_subscriptions = []
for sub in all_autopay_subscriptions:
days_before_expiry = (sub.end_date - current_time).days
@@ -248,24 +299,20 @@ class MonitoringService:
renewal_cost = settings.PRICE_30_DAYS
# Проверяем, не списывали ли уже сегодня
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
if autopay_key in self._notified_users:
continue
if user.balance_kopeks >= renewal_cost:
# Списываем средства
success = await subtract_user_balance(
db, user, renewal_cost,
"Автопродление подписки"
)
if success:
# Продлеваем подписку
await extend_subscription(db, subscription, 30)
await self.subscription_service.update_remnawave_user(db, subscription)
# Уведомляем об успешном автоплатеже
if self.bot:
await self._send_autopay_success_notification(user, renewal_cost, 30)
@@ -279,7 +326,6 @@ class MonitoringService:
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
else:
failed_count += 1
# Уведомляем о недостатке средств
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
@@ -294,54 +340,101 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка обработки автоплатежей: {e}")
# Методы отправки уведомлений
async def _send_subscription_expired_notification(self, user: User):
"""Уведомление об истечении подписки"""
try:
texts = get_texts(user.language)
message = texts.SUBSCRIPTION_EXPIRED
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
message = """
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")]
])
await self.bot.send_message(
user.telegram_id,
message,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
"""Уведомление об истечении подписки через N дней"""
try:
from app.utils.formatters import format_days_declension
texts = get_texts(user.language)
message = texts.SUBSCRIPTION_EXPIRING.format(days=days)
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
days_text = format_days_declension(days, user.language)
if subscription.autopay_enabled:
autopay_status = "✅ Включен - подписка продлится автоматически"
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
else:
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
message = f"""
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⏰ Продлить подписку", callback_data="subscription_extend")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
])
await self.bot.send_message(
user.telegram_id,
message,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
async def _send_trial_ending_notification(self, user: User, subscription: Subscription):
"""Уведомление об окончании тестовой подписки через 2 часа"""
try:
texts = get_texts(user.language)
# Создаем специальное сообщение для тестовой подписки
message = f"""
🎁 <b>Тестовая подписка скоро закончится!</b>
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Успейте оформить до окончания тестового периода!
"""
Успейте оформить до окончания тестового периода!
"""
# Добавляем inline клавиатуру с кнопкой покупки
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="buy_subscription")],
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_top_up")]
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")]
])
await self.bot.send_message(
@@ -355,7 +448,6 @@ class MonitoringService:
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
"""Уведомление об успешном автоплатеже"""
try:
texts = get_texts(user.language)
message = texts.AUTOPAY_SUCCESS.format(
@@ -367,7 +459,6 @@ class MonitoringService:
logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}")
async def _send_autopay_failed_notification(self, user: User, balance: int, required: int):
"""Уведомление о неудачном автоплатеже"""
try:
texts = get_texts(user.language)
message = texts.AUTOPAY_FAILED.format(
@@ -375,11 +466,11 @@ class MonitoringService:
required=settings.format_price(required)
)
# Добавляем кнопку пополнения баланса
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_top_up")]
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
])
await self.bot.send_message(
@@ -392,7 +483,6 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}")
# Остальные методы остаются без изменений...
async def _cleanup_inactive_users(self, db: AsyncSession):
try:
now = datetime.utcnow()
@@ -523,7 +613,6 @@ class MonitoringService:
async def force_check_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
try:
# Проверяем истекшие
expired_subscriptions = await get_expired_subscriptions(db)
expired_count = 0
@@ -618,4 +707,4 @@ class MonitoringService:
return 0
monitoring_service = MonitoringService()
monitoring_service = MonitoringService()
+36 -9
View File
@@ -86,32 +86,59 @@ class PromoCodeService:
effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}")
if promocode.subscription_days > 0:
from app.database.crud.subscription import create_paid_subscription
from app.config import settings
subscription = await get_subscription_by_user_id(db, user.id)
if subscription:
await extend_subscription(db, subscription, promocode.subscription_days)
await self.subscription_service.update_remnawave_user(db, subscription)
effects.append(f"⏰ Подписка продлена на {promocode.subscription_days} дней")
logger.info(f"✅ Подписка пользователя {user.telegram_id} продлена на {promocode.subscription_days} дней в RemnaWave с текущими сквадами")
else:
await create_paid_subscription(
from app.database.crud.subscription import create_paid_subscription
trial_squads = []
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
trial_squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=promocode.subscription_days,
traffic_limit_gb=0,
traffic_limit_gb=0,
device_limit=1,
connected_squads=[]
connected_squads=trial_squads
)
await self.subscription_service.create_remnawave_user(db, new_subscription)
effects.append(f"🎉 Получена подписка на {promocode.subscription_days} дней")
logger.info(f"✅ Создана новая подписка для пользователя {user.telegram_id} на {promocode.subscription_days} дней с триал сквадом {trial_squads}")
if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value:
from app.database.crud.subscription import create_trial_subscription
from app.config import settings
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription and not user.has_had_paid_subscription:
await create_trial_subscription(db, user.id)
effects.append("🎁 Активирована тестовая подписка")
if not subscription:
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
trial_subscription = await create_trial_subscription(
db,
user.id,
duration_days=trial_days
)
await self.subscription_service.create_remnawave_user(db, trial_subscription)
effects.append(f"🎁 Активирована тестовая подписка на {trial_days} дней")
logger.info(f"✅ Создана триал подписка для пользователя {user.telegram_id} на {trial_days} дней")
else:
effects.append("Тестовая подписка уже недоступна")
effects.append("У вас уже есть активная подписка")
return "\n".join(effects) if effects else "✅ Промокод активирован"
return "\n".join(effects) if effects else "✅ Промокод активирован"
+538 -12
View File
@@ -1,6 +1,7 @@
import logging
from typing import Dict, List, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from app.config import settings
from app.external.remnawave_api import (
@@ -9,7 +10,10 @@ from app.external.remnawave_api import (
)
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
from app.database.crud.subscription import get_subscription_by_user_id, update_subscription_usage
from app.database.models import User
from app.database.models import (
User, SubscriptionServer, Transaction, ReferralEarning,
PromoCodeUse, SubscriptionStatus
)
logger = logging.getLogger(__name__)
@@ -392,7 +396,7 @@ class RemnaWaveService:
async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]:
try:
stats = {"created": 0, "updated": 0, "errors": 0}
stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0}
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
@@ -401,7 +405,12 @@ class RemnaWaveService:
panel_users = panel_users_data['response']['users']
logger.info(f"👥 Найдено пользователей в панели: {len(panel_users)}")
bot_users = await get_users_list(db, offset=0, limit=10000)
bot_users_by_telegram_id = {user.telegram_id: user for user in bot_users}
panel_telegram_ids = set()
for i, panel_user in enumerate(panel_users):
try:
telegram_id = panel_user.get('telegramId')
@@ -409,13 +418,15 @@ class RemnaWaveService:
logger.debug(f"➡️ Пропускаем пользователя без telegram_id")
continue
panel_telegram_ids.add(telegram_id)
logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(panel_users)}: {telegram_id}")
db_user = await get_user_by_telegram_id(db, telegram_id)
db_user = bot_users_by_telegram_id.get(telegram_id)
if not db_user:
if sync_type in ["new_only", "all"]:
logger.info(f"📝 Создание пользователя для telegram_id {telegram_id}")
logger.info(f"🆕 Создание пользователя для telegram_id {telegram_id}")
from app.database.crud.user import create_user
@@ -450,13 +461,124 @@ class RemnaWaveService:
logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}")
stats["errors"] += 1
continue
if sync_type == "all":
logger.info("🗑️ ПОЛНАЯ очистка подписок пользователей, отсутствующих в панели...")
for telegram_id, db_user in bot_users_by_telegram_id.items():
if telegram_id not in panel_telegram_ids and db_user.subscription:
try:
logger.info(f"🗑️ ПОЛНАЯ очистка данных подписки пользователя {telegram_id} (нет в панели)")
subscription = db_user.subscription
if db_user.remnawave_uuid:
try:
devices_reset = await api.reset_user_devices(db_user.remnawave_uuid)
if devices_reset:
logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}")
else:
logger.warning(f"⚠️ Не удалось сбросить HWID устройства для пользователя {telegram_id}")
except Exception as hwid_error:
logger.error(f"❌ Ошибка сброса HWID устройств для {telegram_id}: {hwid_error}")
try:
from app.database.crud.subscription import get_subscription_server_ids, remove_subscription_servers
from sqlalchemy import delete
from app.database.models import SubscriptionServer
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == subscription.id
)
)
logger.info(f"🗑️ УДАЛЕНЫ ВСЕ записи SubscriptionServer для подписки {subscription.id}")
except Exception as servers_error:
logger.warning(f"⚠️ Не удалось удалить серверы подписки: {servers_error}")
try:
from sqlalchemy import delete
from app.database.models import Transaction
await db.execute(
delete(Transaction).where(Transaction.user_id == db_user.id)
)
logger.info(f"🗑️ УДАЛЕНЫ ВСЕ транзакции пользователя {telegram_id}")
except Exception as transactions_error:
logger.warning(f"⚠️ Не удалось удалить транзакции: {transactions_error}")
try:
from sqlalchemy import delete
from app.database.models import ReferralEarning
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == db_user.id)
)
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == db_user.id)
)
logger.info(f"🗑️ УДАЛЕНЫ ВСЕ реферальные доходы пользователя {telegram_id}")
except Exception as referral_error:
logger.warning(f"⚠️ Не удалось удалить реферальные доходы: {referral_error}")
try:
from sqlalchemy import delete
from app.database.models import PromoCodeUse
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == db_user.id)
)
logger.info(f"🗑️ УДАЛЕНЫ ВСЕ использования промокодов пользователя {telegram_id}")
except Exception as promo_error:
logger.warning(f"⚠️ Не удалось удалить использования промокодов: {promo_error}")
try:
db_user.balance_kopeks = 0
logger.info(f"💰 Сброшен баланс пользователя {telegram_id}")
except Exception as balance_error:
logger.warning(f"⚠️ Не удалось сбросить баланс: {balance_error}")
from app.database.models import SubscriptionStatus
from datetime import datetime
subscription.status = SubscriptionStatus.DISABLED.value
subscription.is_trial = True
subscription.end_date = datetime.utcnow()
subscription.traffic_limit_gb = 0
subscription.traffic_used_gb = 0.0
subscription.device_limit = 1
subscription.connected_squads = []
subscription.autopay_enabled = False
subscription.autopay_days_before = 3
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
db_user.remnawave_uuid = None
db_user.has_had_paid_subscription = False
db_user.used_promocodes = 0
await db.commit()
stats["deleted"] += 1
logger.info(f"✅ ПОЛНОСТЬЮ очищены ВСЕ данные подписки пользователя {telegram_id}")
except Exception as delete_error:
logger.error(f"❌ Ошибка полной очистки данных подписки {telegram_id}: {delete_error}")
stats["errors"] += 1
await db.rollback()
logger.info(f"🎯 Синхронизация завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}")
logger.info(f"🎯 Синхронизация завершена: создано {stats['created']}, обновлено {stats['updated']}, удалено {stats['deleted']}, ошибок {stats['errors']}")
return stats
except Exception as e:
logger.error(f"❌ Критическая ошибка синхронизации пользователей: {e}")
return {"created": 0, "updated": 0, "errors": 1}
return {"created": 0, "updated": 0, "errors": 1, "deleted": 0}
async def _create_subscription_from_panel_data(self, db: AsyncSession, user, panel_user):
try:
@@ -549,6 +671,7 @@ class RemnaWaveService:
async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user):
try:
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import SubscriptionStatus
from datetime import datetime, timedelta
subscription = await get_subscription_by_user_id(db, user.id)
@@ -557,17 +680,62 @@ class RemnaWaveService:
await self._create_subscription_from_panel_data(db, user, panel_user)
return
panel_status = panel_user.get('status', 'ACTIVE')
expire_at_str = panel_user.get('expireAt', '')
try:
if expire_at_str:
if expire_at_str.endswith('Z'):
expire_at_str = expire_at_str[:-1] + '+00:00'
expire_at = datetime.fromisoformat(expire_at_str)
if expire_at.tzinfo is not None:
expire_at = expire_at.replace(tzinfo=None)
if abs((subscription.end_date - expire_at).total_seconds()) > 60:
subscription.end_date = expire_at
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
except Exception as date_error:
logger.warning(f"⚠️ Ошибка парсинга даты при обновлении {expire_at_str}: {date_error}")
current_time = datetime.utcnow()
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
new_status = SubscriptionStatus.ACTIVE.value
elif subscription.end_date <= current_time:
new_status = SubscriptionStatus.EXPIRED.value
elif panel_status == 'DISABLED':
new_status = SubscriptionStatus.DISABLED.value
else:
new_status = subscription.status
if subscription.status != new_status:
subscription.status = new_status
logger.debug(f"Обновлен статус подписки: {new_status}")
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
traffic_used_gb = used_traffic_bytes / (1024**3)
if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01:
subscription.traffic_used_gb = traffic_used_gb
logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB")
traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0)
traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0
if subscription.traffic_limit_gb != traffic_limit_gb:
subscription.traffic_limit_gb = traffic_limit_gb
logger.debug(f"Обновлен лимит трафика: {traffic_limit_gb} GB")
device_limit = panel_user.get('hwidDeviceLimit', 1) or 1
if subscription.device_limit != device_limit:
subscription.device_limit = device_limit
logger.debug(f"Обновлен лимит устройств: {device_limit}")
if not subscription.remnawave_short_uuid:
subscription.remnawave_short_uuid = panel_user.get('shortUuid')
if not subscription.subscription_url:
subscription.subscription_url = panel_user.get('subscriptionUrl', '')
panel_url = panel_user.get('subscriptionUrl', '')
if not subscription.subscription_url or subscription.subscription_url != panel_url:
subscription.subscription_url = panel_url
active_squads = panel_user.get('activeInternalSquads', [])
squad_uuids = []
@@ -578,14 +746,19 @@ class RemnaWaveService:
elif isinstance(squad, str):
squad_uuids.append(squad)
if squad_uuids != subscription.connected_squads:
current_squads = set(subscription.connected_squads or [])
new_squads = set(squad_uuids)
if current_squads != new_squads:
subscription.connected_squads = squad_uuids
logger.debug(f"Обновлены подключенные сквады: {squad_uuids}")
await db.commit()
logger.debug(f"✅ Обновлена подписка для пользователя {user.telegram_id}")
except Exception as e:
logger.error(f"❌ Ошибка обновления подписки для пользователя {user.telegram_id}: {e}")
await db.rollback()
async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]:
try:
@@ -627,7 +800,6 @@ class RemnaWaveService:
active_internal_squads=subscription.connected_squads
)
# Обновляем UUID в нашей базе
await update_user(db, user, remnawave_uuid=new_user.uuid)
subscription.remnawave_short_uuid = new_user.short_uuid
await db.commit()
@@ -865,4 +1037,358 @@ class RemnaWaveService:
except Exception as e:
logger.error(f"Ошибка валидации данных пользователя: {e}")
return False
return False
async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool:
try:
logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}")
if user.remnawave_uuid:
try:
async with self.api as api:
devices_reset = await api.reset_user_devices(user.remnawave_uuid)
if devices_reset:
logger.info(f"🔧 Сброшены HWID устройства для {user.telegram_id}")
except Exception as hwid_error:
logger.warning(f"⚠️ Ошибка сброса HWID устройств: {hwid_error}")
try:
from sqlalchemy import delete
from app.database.models import (
SubscriptionServer, Transaction, ReferralEarning,
PromoCodeUse, SubscriptionStatus
)
if user.subscription:
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
)
)
logger.info(f"🗑️ Удалены серверы подписки для {user.telegram_id}")
await db.execute(
delete(Transaction).where(Transaction.user_id == user.id)
)
logger.info(f"🗑️ Удалены транзакции для {user.telegram_id}")
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
)
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
)
logger.info(f"🗑️ Удалены реферальные доходы для {user.telegram_id}")
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
)
logger.info(f"🗑️ Удалены использования промокодов для {user.telegram_id}")
except Exception as records_error:
logger.error(f"❌ Ошибка удаления связанных записей: {records_error}")
try:
from datetime import datetime
user.balance_kopeks = 0
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.used_promocodes = 0
user.updated_at = datetime.utcnow()
if user.subscription:
user.subscription.status = SubscriptionStatus.DISABLED.value
user.subscription.is_trial = True
user.subscription.end_date = datetime.utcnow()
user.subscription.traffic_limit_gb = 0
user.subscription.traffic_used_gb = 0.0
user.subscription.device_limit = 1
user.subscription.connected_squads = []
user.subscription.autopay_enabled = False
user.subscription.autopay_days_before = 3
user.subscription.remnawave_short_uuid = None
user.subscription.subscription_url = ""
user.subscription.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ ПРИНУДИТЕЛЬНО очищены ВСЕ данные пользователя {user.telegram_id}")
return True
except Exception as cleanup_error:
logger.error(f"❌ Ошибка финальной очистки пользователя: {cleanup_error}")
await db.rollback()
return False
except Exception as e:
logger.error(f"❌ Критическая ошибка принудительной очистки пользователя {user.telegram_id}: {e}")
await db.rollback()
return False
async def cleanup_orphaned_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
"""
Усиленная версия очистки с полным удалением данных
"""
try:
stats = {"deactivated": 0, "errors": 0, "checked": 0}
logger.info("🧹 Начинаем усиленную очистку неактуальных подписок...")
async with self.api as api:
panel_users_data = await api._make_request('GET', '/api/users')
panel_users = panel_users_data['response']['users']
panel_telegram_ids = set()
for panel_user in panel_users:
telegram_id = panel_user.get('telegramId')
if telegram_id:
panel_telegram_ids.add(telegram_id)
logger.info(f"📊 Найдено {len(panel_telegram_ids)} пользователей в панели")
from app.database.crud.subscription import get_all_subscriptions
from app.database.models import SubscriptionStatus
page = 1
limit = 100
while True:
subscriptions, total_count = await get_all_subscriptions(db, page, limit)
if not subscriptions:
break
for subscription in subscriptions:
try:
stats["checked"] += 1
user = subscription.user
if subscription.status == SubscriptionStatus.DISABLED.value:
continue
if user.telegram_id not in panel_telegram_ids:
logger.info(f"🗑️ ПОЛНАЯ деактивация подписки пользователя {user.telegram_id} (отсутствует в панели)")
cleanup_success = await self.force_cleanup_user_data(db, user)
if cleanup_success:
stats["deactivated"] += 1
else:
stats["errors"] += 1
except Exception as sub_error:
logger.error(f"❌ Ошибка обработки подписки {subscription.id}: {sub_error}")
stats["errors"] += 1
page += 1
if len(subscriptions) < limit:
break
logger.info(f"🧹 Усиленная очистка завершена: проверено {stats['checked']}, деактивировано {stats['deactivated']}, ошибок {stats['errors']}")
return stats
except Exception as e:
logger.error(f"❌ Критическая ошибка усиленной очистки подписок: {e}")
return {"deactivated": 0, "errors": 1, "checked": 0}
async def sync_subscription_statuses(self, db: AsyncSession) -> Dict[str, int]:
try:
stats = {"updated": 0, "errors": 0, "checked": 0}
logger.info("🔄 Начинаем синхронизацию статусов подписок...")
async with self.api as api:
panel_users_data = await api._make_request('GET', '/api/users')
panel_users = panel_users_data['response']['users']
panel_users_dict = {}
for panel_user in panel_users:
telegram_id = panel_user.get('telegramId')
if telegram_id:
panel_users_dict[telegram_id] = panel_user
logger.info(f"📊 Найдено {len(panel_users_dict)} пользователей в панели для синхронизации")
from app.database.crud.subscription import get_all_subscriptions
from app.database.models import SubscriptionStatus
from datetime import datetime
page = 1
limit = 100
while True:
subscriptions, total_count = await get_all_subscriptions(db, page, limit)
if not subscriptions:
break
for subscription in subscriptions:
try:
stats["checked"] += 1
user = subscription.user
panel_user = panel_users_dict.get(user.telegram_id)
if panel_user:
await self._update_subscription_from_panel_data(db, user, panel_user)
stats["updated"] += 1
else:
if subscription.status != SubscriptionStatus.DISABLED.value:
logger.info(f"🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)")
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, subscription)
stats["updated"] += 1
except Exception as sub_error:
logger.error(f"❌ Ошибка синхронизации подписки {subscription.id}: {sub_error}")
stats["errors"] += 1
page += 1
if len(subscriptions) < limit:
break
logger.info(f"🔄 Синхронизация статусов завершена: проверено {stats['checked']}, обновлено {stats['updated']}, ошибок {stats['errors']}")
return stats
except Exception as e:
logger.error(f"❌ Критическая ошибка синхронизации статусов: {e}")
return {"updated": 0, "errors": 1, "checked": 0}
async def validate_and_fix_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
try:
stats = {"fixed": 0, "errors": 0, "checked": 0, "issues_found": 0}
logger.info("🔍 Начинаем валидацию подписок...")
from app.database.crud.subscription import get_all_subscriptions
from app.database.models import SubscriptionStatus
from datetime import datetime
page = 1
limit = 100
while True:
subscriptions, total_count = await get_all_subscriptions(db, page, limit)
if not subscriptions:
break
for subscription in subscriptions:
try:
stats["checked"] += 1
user = subscription.user
issues_fixed = 0
current_time = datetime.utcnow()
if subscription.end_date <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value:
logger.info(f"🔧 Исправляем статус просроченной подписки {user.telegram_id}")
subscription.status = SubscriptionStatus.EXPIRED.value
issues_fixed += 1
if not subscription.remnawave_short_uuid and user.remnawave_uuid:
try:
async with self.api as api:
rw_user = await api.get_user_by_uuid(user.remnawave_uuid)
if rw_user:
subscription.remnawave_short_uuid = rw_user.short_uuid
subscription.subscription_url = rw_user.subscription_url
logger.info(f"🔧 Восстановлены данные RemnaWave для {user.telegram_id}")
issues_fixed += 1
except Exception as rw_error:
logger.warning(f"⚠️ Не удалось получить данные RemnaWave для {user.telegram_id}: {rw_error}")
if subscription.traffic_limit_gb < 0:
subscription.traffic_limit_gb = 0
logger.info(f"🔧 Исправлен некорректный лимит трафика для {user.telegram_id}")
issues_fixed += 1
if subscription.traffic_used_gb < 0:
subscription.traffic_used_gb = 0.0
logger.info(f"🔧 Исправлено некорректное использование трафика для {user.telegram_id}")
issues_fixed += 1
if subscription.device_limit <= 0:
subscription.device_limit = 1
logger.info(f"🔧 Исправлен лимит устройств для {user.telegram_id}")
issues_fixed += 1
if subscription.connected_squads is None:
subscription.connected_squads = []
logger.info(f"🔧 Инициализирован список сквадов для {user.telegram_id}")
issues_fixed += 1
if issues_fixed > 0:
stats["issues_found"] += issues_fixed
stats["fixed"] += 1
await db.commit()
except Exception as sub_error:
logger.error(f"❌ Ошибка валидации подписки {subscription.id}: {sub_error}")
stats["errors"] += 1
await db.rollback()
page += 1
if len(subscriptions) < limit:
break
logger.info(f"🔍 Валидация завершена: проверено {stats['checked']}, исправлено подписок {stats['fixed']}, найдено проблем {stats['issues_found']}, ошибок {stats['errors']}")
return stats
except Exception as e:
logger.error(f"❌ Критическая ошибка валидации: {e}")
return {"fixed": 0, "errors": 1, "checked": 0, "issues_found": 0}
async def get_sync_recommendations(self, db: AsyncSession) -> Dict[str, Any]:
try:
recommendations = {
"should_sync": False,
"sync_type": "none",
"reasons": [],
"priority": "low",
"estimated_time": "1-2 минуты"
}
from app.database.crud.user import get_users_list
bot_users = await get_users_list(db, offset=0, limit=10000)
users_without_uuid = sum(1 for user in bot_users if not user.remnawave_uuid and user.subscription)
from app.database.crud.subscription import get_expired_subscriptions
expired_subscriptions = await get_expired_subscriptions(db)
active_expired = sum(1 for sub in expired_subscriptions if sub.status == "active")
if users_without_uuid > 10:
recommendations["should_sync"] = True
recommendations["sync_type"] = "all"
recommendations["priority"] = "high"
recommendations["reasons"].append(f"Найдено {users_without_uuid} пользователей без связи с RemnaWave")
recommendations["estimated_time"] = "3-5 минут"
if active_expired > 5:
recommendations["should_sync"] = True
if recommendations["sync_type"] == "none":
recommendations["sync_type"] = "update_only"
recommendations["priority"] = "medium" if recommendations["priority"] == "low" else recommendations["priority"]
recommendations["reasons"].append(f"Найдено {active_expired} активных подписок с истекшим сроком")
if not recommendations["should_sync"]:
recommendations["sync_type"] = "update_only"
recommendations["reasons"].append("Рекомендуется регулярная синхронизация данных")
recommendations["estimated_time"] = "1-2 минуты"
return recommendations
except Exception as e:
logger.error(f"❌ Ошибка получения рекомендаций: {e}")
return {
"should_sync": True,
"sync_type": "all",
"reasons": ["Ошибка анализа - рекомендуется полная синхронизация"],
"priority": "medium",
"estimated_time": "3-5 минут"
}
+172 -16
View File
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import Subscription, User
from app.database.models import Subscription, User, SubscriptionStatus
from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, UserStatus,
TrafficLimitStrategy, RemnaWaveAPIError
@@ -34,10 +34,23 @@ class SubscriptionService:
logger.error(f"Пользователь {subscription.user_id} не найден")
return None
validation_success = await self.validate_and_clean_subscription(db, subscription, user)
if not validation_success:
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
return None
async with self.api as api:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
remnawave_user = existing_users[0]
try:
await api.reset_user_devices(remnawave_user.uuid)
logger.info(f"🔧 Сброшены HWID устройства для пользователя {user.telegram_id}")
except Exception as hwid_error:
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
updated_user = await api.update_user(
uuid=remnawave_user.uuid,
status=UserStatus.ACTIVE,
@@ -47,7 +60,9 @@ class SubscriptionService:
hwid_device_limit=subscription.device_limit,
active_internal_squads=subscription.connected_squads
)
else:
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
username = f"user_{user.telegram_id}"
updated_user = await api.create_user(
username=username,
@@ -67,7 +82,7 @@ class SubscriptionService:
await db.commit()
logger.info(f"✅ Создан RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
return updated_user
@@ -91,10 +106,23 @@ class SubscriptionService:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
current_time = datetime.utcnow()
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
await db.commit()
is_actually_active = False
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
async with self.api as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
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=TrafficLimitStrategy.MONTH,
@@ -105,12 +133,13 @@ class SubscriptionService:
subscription.subscription_url = updated_user.subscription_url
await db.commit()
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid}")
status_text = "активным" if is_actually_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
logger.error(f"Ошибка RemnaWave API: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
@@ -200,34 +229,161 @@ class SubscriptionService:
devices: int,
db: AsyncSession
) -> Tuple[int, List[int]]:
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
from app.database.crud.server_squad import get_server_squad_by_id
base_price = PERIOD_PRICES.get(period_days, 0)
traffic_price = TRAFFIC_PRICES.get(traffic_gb, 0)
server_prices = []
total_servers_price = 0
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_prices.append(server.price_kopeks)
total_servers_price += server.price_kopeks
logger.debug(f"🏷️ Сервер {server.display_name}: {server.price_kopeks/100}")
else:
server_prices.append(0)
logger.warning(f"⚠️ Сервер ID {server_id} недоступен")
devices_price = max(0, devices - 1) * settings.PRICE_PER_DEVICE
total_price = base_price + traffic_price + total_servers_price + devices_price
logger.info(f"💰 Расчет стоимости новой подписки:")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 📊 Трафик {traffic_gb} ГБ: {traffic_price/100}")
logger.info(f" 🌍 Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" 📱 Устройства ({devices}): {devices_price/100}")
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price, server_prices
async def _get_countries_price(self, country_uuids: List[str]) -> int:
# TODO: Реализовать получение цен из базы данных сквадов
# Пока возвращаем базовую логику
price_per_country = 1000
return len(country_uuids) * price_per_country
async def calculate_renewal_price(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession
) -> int:
try:
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
base_price = PERIOD_PRICES.get(period_days, 0)
servers_price, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads, db
)
devices_price = max(0, subscription.device_limit - 1) * settings.PRICE_PER_DEVICE
traffic_price = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
total_price = base_price + servers_price + devices_price + traffic_price
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}")
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}")
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}")
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price
except Exception as e:
logger.error(f"Ошибка расчета стоимости продления: {e}")
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
async def validate_and_clean_subscription(
self,
db: AsyncSession,
subscription: Subscription,
user: User
) -> bool:
try:
needs_cleanup = False
if user.remnawave_uuid:
try:
async with self.api as api:
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not remnawave_user:
logger.warning(f"⚠️ Пользователь {user.telegram_id} имеет UUID {user.remnawave_uuid}, но не найден в панели")
needs_cleanup = True
else:
if remnawave_user.telegram_id != user.telegram_id:
logger.warning(f"⚠️ Несоответствие telegram_id для пользователя {user.telegram_id}")
needs_cleanup = True
except Exception as api_error:
logger.error(f"❌ Ошибка проверки пользователя в панели: {api_error}")
needs_cleanup = True
if subscription.remnawave_short_uuid and not user.remnawave_uuid:
logger.warning(f"⚠️ У подписки есть short_uuid, но у пользователя нет remnawave_uuid")
needs_cleanup = True
if needs_cleanup:
logger.info(f"🧹 Очищаем мусорные данные подписки для пользователя {user.telegram_id}")
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
subscription.connected_squads = []
user.remnawave_uuid = None
await db.commit()
logger.info(f"✅ Мусорные данные очищены для пользователя {user.telegram_id}")
return True
except Exception as e:
logger.error(f"❌ Ошибка валидации подписки для пользователя {user.telegram_id}: {e}")
await db.rollback()
return False
async def get_countries_price_by_uuids(
self,
country_uuids: List[str],
db: AsyncSession
) -> Tuple[int, List[int]]:
try:
from app.database.crud.server_squad import get_server_squad_by_uuid
total_price = 0
prices_list = []
for country_uuid in country_uuids:
server = await get_server_squad_by_uuid(db, country_uuid)
if server and server.is_available and not server.is_full:
price = server.price_kopeks
total_price += price
prices_list.append(price)
logger.debug(f"🏷️ Страна {server.display_name}: {price/100}")
else:
default_price = 1000
total_price += default_price
prices_list.append(default_price)
logger.warning(f"⚠️ Сервер {country_uuid} недоступен, используем базовую цену: {default_price/100}")
logger.info(f"💰 Общая стоимость стран: {total_price/100}")
return total_price, prices_list
except Exception as e:
logger.error(f"Ошибка получения цен стран: {e}")
default_prices = [1000] * len(country_uuids)
return sum(default_prices), default_prices
async def _get_countries_price(self, country_uuids: List[str], db: AsyncSession) -> int:
try:
total_price, _ = await self.get_countries_price_by_uuids(country_uuids, db)
return total_price
except Exception as e:
logger.error(f"Ошибка получения цен стран: {e}")
return len(country_uuids) * 1000
def _gb_to_bytes(self, gb: int) -> int:
if gb == 0:
@@ -237,4 +393,4 @@ class SubscriptionService:
def _bytes_to_gb(self, bytes_value: int) -> float:
if bytes_value == 0:
return 0.0
return bytes_value / (1024 * 1024 * 1024)
return bytes_value / (1024 * 1024 * 1024)
+130 -9
View File
@@ -2,6 +2,7 @@ import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
@@ -10,7 +11,10 @@ from app.database.crud.user import (
)
from app.database.crud.transaction import get_user_transactions_count
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import User, UserStatus
from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCodeUse,
ReferralEarning, SubscriptionServer
)
from app.config import settings
logger = logging.getLogger(__name__)
@@ -157,6 +161,19 @@ class UserService:
if not user:
return False
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован при блокировке")
except Exception as e:
logger.error(f"❌ Ошибка деактивации RemnaWave пользователя при блокировке: {e}")
if user.subscription:
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
await update_user(db, user, status=UserStatus.BLOCKED.value)
logger.info(f"Админ {admin_id} заблокировал пользователя {user_id}: {reason}")
@@ -179,6 +196,27 @@ class UserService:
await update_user(db, user, status=UserStatus.ACTIVE.value)
if user.subscription:
from datetime import datetime
from app.database.models import SubscriptionStatus
if user.subscription.end_date > datetime.utcnow():
user.subscription.status = SubscriptionStatus.ACTIVE.value
await db.commit()
await db.refresh(user.subscription)
logger.info(f"🔄 Подписка пользователя {user_id} восстановлена")
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} восстановлен при разблокировке")
except Exception as e:
logger.error(f"❌ Ошибка восстановления RemnaWave пользователя при разблокировке: {e}")
else:
logger.info(f"⏰ Подписка пользователя {user_id} истекла, восстановление невозможно")
logger.info(f"Админ {admin_id} разблокировал пользователя {user_id}")
return True
@@ -195,17 +233,100 @@ class UserService:
try:
user = await get_user_by_id(db, user_id)
if not user:
logger.warning(f"Пользователь {user_id} не найден для удаления")
return False
success = await delete_user(db, user)
logger.info(f"🗑️ Начинаем полное удаление пользователя {user_id} (Telegram ID: {user.telegram_id})")
if success:
logger.info(f"Админ {admin_id} удалил пользователя {user_id}")
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
except Exception as e:
logger.error(f"❌ Ошибка деактивации RemnaWave пользователя: {e}")
return success
if user.subscription:
try:
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
)
)
logger.info(f"🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления SubscriptionServer: {e}")
if user.subscription:
try:
await db.delete(user.subscription)
logger.info(f"🗑️ Удалена подписка пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления подписки: {e}")
try:
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
)
logger.info(f"🗑️ Удалены использования промокодов пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления использований промокодов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
)
logger.info(f"🗑️ Удалены реферальные доходы пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных доходов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
)
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
try:
from sqlalchemy import update
await db.execute(
update(User)
.where(User.referred_by_id == user.id)
.values(referred_by_id=None)
)
logger.info(f"🗑️ Очищены реферальные ссылки у рефералов пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
try:
await db.execute(
delete(Transaction).where(Transaction.user_id == user.id)
)
logger.info(f"🗑️ Удалены транзакции пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления транзакций: {e}")
try:
user.status = UserStatus.DELETED.value
user.balance_kopeks = 0
user.remnawave_uuid = None
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ Пользователь {user_id} помечен как удаленный и обнулен")
except Exception as e:
logger.error(f"❌ Ошибка обновления статуса пользователя: {e}")
await db.rollback()
return False
logger.info(f"✅ Пользователь {user_id} (Telegram ID: {user.telegram_id}) полностью удален админом {admin_id}")
return True
except Exception as e:
logger.error(f"Ошибка удаления пользователя: {e}")
logger.error(f"❌ Критическая ошибка удаления пользователя {user_id}: {e}")
await db.rollback()
return False
async def get_user_statistics(self, db: AsyncSession) -> Dict[str, Any]:
@@ -237,7 +358,7 @@ class UserService:
deleted_count = 0
for user in inactive_users:
success = await delete_user(db, user)
success = await self.delete_user_account(db, user.id, 0)
if success:
deleted_count += 1
@@ -281,7 +402,7 @@ class UserService:
"subscription_active": subscription.is_active if subscription else False,
"subscription_trial": subscription.is_trial if subscription else False,
"transactions_count": transactions_count,
"referrer_id": user.referrer_id,
"referrer_id": user.referred_by_id,
"referral_code": user.referral_code
}
@@ -330,4 +451,4 @@ class UserService:
except Exception as e:
logger.error(f"Ошибка получения пользователей по критериям: {e}")
return []
return []
+4 -1
View File
@@ -35,6 +35,9 @@ class AdminStates(StatesGroup):
waiting_for_user_search = State()
editing_user_balance = State()
extending_subscription = State()
adding_traffic = State()
granting_subscription = State()
editing_user_subscription = State()
creating_promocode = State()
@@ -79,4 +82,4 @@ class SquadCreateStates(StatesGroup):
selecting_inbounds = State()
class SquadRenameStates(StatesGroup):
waiting_for_new_name = State()
waiting_for_new_name = State()
+12 -1
View File
@@ -67,6 +67,17 @@ def format_time_ago(dt: Union[datetime, str]) -> str:
else:
return "только что"
def format_days_declension(days: int, language: str = "ru") -> str:
if language != "ru":
return f"{days} day{'s' if days != 1 else ''}"
if days % 10 == 1 and days % 100 != 11:
return f"{days} день"
elif days % 10 in [2, 3, 4] and days % 100 not in [12, 13, 14]:
return f"{days} дня"
else:
return f"{days} дней"
def format_duration(seconds: int) -> str:
if seconds < 60:
@@ -204,4 +215,4 @@ def format_boolean(value: bool, language: str = "ru") -> str:
if language == "ru":
return "✅ Да" if value else "❌ Нет"
else:
return "✅ Yes" if value else "❌ No"
return "✅ Yes" if value else "❌ No"
+97
View File
@@ -0,0 +1,97 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, User
logger = logging.getLogger(__name__)
async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Optional[Subscription]:
result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
)
subscriptions = result.scalars().all()
if len(subscriptions) <= 1:
return subscriptions[0] if subscriptions else None
latest_subscription = subscriptions[0]
old_subscriptions = subscriptions[1:]
logger.warning(f"🚨 Обнаружено {len(subscriptions)} подписок у пользователя {user_id}. Удаляем {len(old_subscriptions)} старых.")
for old_sub in old_subscriptions:
await db.delete(old_sub)
logger.info(f"🗑️ Удалена подписка ID {old_sub.id} от {old_sub.created_at}")
await db.commit()
await db.refresh(latest_subscription)
logger.info(f"✅ Оставлена подписка ID {latest_subscription.id} от {latest_subscription.created_at}")
return latest_subscription
async def update_or_create_subscription(
db: AsyncSession,
user_id: int,
**subscription_data
) -> Subscription:
existing_subscription = await ensure_single_subscription(db, user_id)
if existing_subscription:
for key, value in subscription_data.items():
if hasattr(existing_subscription, key):
setattr(existing_subscription, key, value)
existing_subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing_subscription)
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
return existing_subscription
else:
new_subscription = Subscription(
user_id=user_id,
**subscription_data
)
db.add(new_subscription)
await db.commit()
await db.refresh(new_subscription)
logger.info(f"🆕 Создана новая подписка ID {new_subscription.id}")
return new_subscription
async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int:
result = await db.execute(
select(Subscription.user_id)
.group_by(Subscription.user_id)
.having(func.count(Subscription.id) > 1)
)
users_with_duplicates = result.scalars().all()
total_deleted = 0
for user_id in users_with_duplicates:
subscriptions_result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
)
subscriptions = subscriptions_result.scalars().all()
for old_subscription in subscriptions[1:]:
await db.delete(old_subscription)
total_deleted += 1
logger.info(f"🗑️ Удалена дублирующаяся подписка ID {old_subscription.id} пользователя {user_id}")
await db.commit()
logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок")
return total_deleted
+22 -3
View File
@@ -1,6 +1,7 @@
import asyncio
import logging
import sys
import os
from pathlib import Path
sys.path.append(str(Path(__file__).parent))
@@ -10,6 +11,7 @@ from app.config import settings
from app.database.database import init_db
from app.services.monitoring_service import monitoring_service
from app.external.webhook_server import WebhookServer
from app.database.universal_migration import run_universal_migration
async def main():
@@ -23,7 +25,7 @@ async def main():
)
logger = logging.getLogger(__name__)
logger.info("🚀 Запуск VPN бота...")
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
webhook_server = None
@@ -31,12 +33,29 @@ async def main():
logger.info("📊 Инициализация базы данных...")
await init_db()
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
if not skip_migration:
logger.info("🔧 Выполняем проверку и миграцию базы данных...")
try:
migration_success = await run_universal_migration()
if migration_success:
logger.info("✅ Миграция базы данных завершена успешно")
else:
logger.warning("⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск")
except Exception as migration_error:
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
logger.warning("⚠️ Продолжаем запуск без миграции")
else:
logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)")
logger.info("🤖 Настройка бота...")
bot, dp = await setup_bot()
monitoring_service.bot = bot
# Инициализируем webhook сервер если Tribute включен
if settings.TRIBUTE_ENABLED:
logger.info("🌐 Запуск webhook сервера для Tribute...")
webhook_server = WebhookServer(bot)
@@ -78,4 +97,4 @@ if __name__ == "__main__":
print("\n🛑 Бот остановлен пользователем")
except Exception as e:
print(f"❌ Критическая ошибка: {e}")
sys.exit(1)
sys.exit(1)
@@ -0,0 +1,29 @@
"""add_paid_price_to_subscription
Revision ID: 3d9b35c6bd8f
Revises:
Create Date: 2025-08-23 08:17:00.563340
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3d9b35c6bd8f'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('subscriptions', sa.Column('paid_price_kopeks', sa.Integer(), nullable=False, server_default='0'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('subscriptions', 'paid_price_kopeks')
# ### end Alembic commands ###
+2 -2
View File
@@ -1,12 +1,12 @@
# Основные зависимости
aiogram==3.4.1
aiogram==3.7.0
aiohttp==3.9.1
asyncpg==0.29.0
SQLAlchemy==2.0.25
alembic==1.13.1
aiosqlite==0.19.0
# Дополнительные зависимости
# Дополнительные зависимости
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB