Compare commits
446 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f307ad03bf | |||
| f150bfad37 | |||
| 8c22df1f52 | |||
| f396ca7df4 | |||
| 0b1a2b7f68 | |||
| d92b9559ac | |||
| fb3942e29d | |||
| 6009d15a51 | |||
| e2469a14c9 | |||
| 4daacae0d9 | |||
| 47dbced347 | |||
| 49b129a8f4 | |||
| f8315ba18e | |||
| a9809e74c9 | |||
| 4fe61ec870 | |||
| 172ee773ee | |||
| b8edc4ec0d | |||
| d8f2f413a8 | |||
| 360a04fbe8 | |||
| 2613d42b57 | |||
| a06d55af38 | |||
| bf28617d99 | |||
| 878a28cb11 | |||
| ade062c7cd | |||
| 5d774bd1aa | |||
| 8d81bfcf94 | |||
| 095c3a8490 | |||
| f7c8c82775 | |||
| 3d75543cf0 | |||
| 1223948596 | |||
| f12964ecc8 | |||
| 693dce2e28 | |||
| 166050c581 | |||
| d499574c4d | |||
| 98363e2c28 | |||
| d87622417a | |||
| 8be1af5adb | |||
| 72a2bb7d04 | |||
| a847d4c1f6 | |||
| b860752d80 | |||
| 0a466b973b | |||
| 9ca0ca2db2 | |||
| af3f746ae1 | |||
| c64c53a26b | |||
| 765e912dd1 | |||
| f205acfb0a | |||
| 3c424143dc | |||
| ba87ffa0be | |||
| f211c93794 | |||
| 8994e3e9ba | |||
| 6018c07c0d | |||
| 41bfffdf12 | |||
| 1a06b88db9 | |||
| bd6d9013ae | |||
| 093d178bf8 | |||
| 6169f34911 | |||
| 3aca29777c | |||
| c7f2b0752b | |||
| e113b7e842 | |||
| 359bbaa31c | |||
| 862aeee558 | |||
| 4c3a3b91a6 | |||
| 199e78cb25 | |||
| 87845e2691 | |||
| 0c30d1ea30 | |||
| 2f64aa0961 | |||
| 2266609111 | |||
| 28e9cf6ab7 | |||
| 7c55c54f23 | |||
| 060d0a6970 | |||
| f0465a9ea4 | |||
| 29db1f4e56 | |||
| 755ef03f98 | |||
| 458b71ea28 | |||
| f1f409dc63 | |||
| 64d0f9fc2d | |||
| 01db9036f5 | |||
| 674e1b73f6 | |||
| 9bfb9bc711 | |||
| 96f905563f | |||
| 6b278de538 | |||
| c6fc770fc1 | |||
| e50f208064 | |||
| 637af5c216 | |||
| 08373be215 | |||
| 5e4c7e339a | |||
| 476dbcca93 | |||
| e8e4330b1c | |||
| d5c0146d48 | |||
| 83c461230c | |||
| f27a51639c | |||
| ab8d4f0340 | |||
| 1ee15bf6d3 | |||
| 56238ad93c | |||
| 2fa2c6278c | |||
| 519978f634 | |||
| 7095d8f015 | |||
| e8dcfa0687 | |||
| a0e12fa89a | |||
| 097be9aa0f | |||
| f50b5296f4 | |||
| 14e94053ee | |||
| 3c27fa4d90 | |||
| 2a78ace675 | |||
| 81774f87d8 | |||
| 6735c2b38f | |||
| 8bdc5a2332 | |||
| d26262bfbf | |||
| 48f2f01bdd | |||
| ef68a43589 | |||
| 733c1b72ec | |||
| c9ca360286 | |||
| 5aad1e025b | |||
| e811e7c877 | |||
| 932e9f292b | |||
| 03749574d6 | |||
| 1059a51688 | |||
| a1963d5b17 | |||
| 426e736cd5 | |||
| fb03cff403 | |||
| 40780f1184 | |||
| c76290b51e | |||
| 5b6dd3058e | |||
| c8d91a1ec1 | |||
| 21952f84eb | |||
| 7bdfaa312b | |||
| 1829a469e7 | |||
| ddd37a4e98 | |||
| 2804c701c0 | |||
| b3c85b2db5 | |||
| 987b8170c6 | |||
| ab49f3cd1e | |||
| 732e8f7b8b | |||
| 9273a4e15b | |||
| d89293f76a | |||
| 174462183d | |||
| 783927be37 | |||
| 98c68dd8dc | |||
| 13585eab54 | |||
| 169336da43 | |||
| 9ef875f6bf | |||
| 3f54706a72 | |||
| 835e5e6f7f | |||
| b292442e8d | |||
| 9faaf5c963 | |||
| 117e88fe4a | |||
| 96bf08a07c | |||
| 4523403de5 | |||
| 02682e88c1 | |||
| 323b9a7e18 | |||
| 100dc3730f | |||
| 919047317a | |||
| 6ae5b59ed1 | |||
| b3123e2e85 | |||
| fbe0ca790e | |||
| 785648ba7a | |||
| 06f8728efa | |||
| 99f102a06e | |||
| d759dd3a66 | |||
| 73dc22b0b8 | |||
| 5a082f0eb8 | |||
| 073d5a119b | |||
| 2dab106bef | |||
| ec8c7b7242 | |||
| a4b96a089f | |||
| 269f8f6220 | |||
| f1beda2b89 | |||
| 6a00db32ae | |||
| 276bc5eb40 | |||
| 8678afd055 | |||
| 31340ad4a5 | |||
| 98ce47ce71 | |||
| 3462f5b3b1 | |||
| 7ec606f93c | |||
| 7d13bf47aa | |||
| 87773806fc | |||
| dad6200ad8 | |||
| c3991162c2 | |||
| 08feb1f876 | |||
| 6f15070052 | |||
| 0c4e73ab92 | |||
| 48902c4968 | |||
| 3c715577a6 | |||
| 8e67c7a97a | |||
| c938d48026 | |||
| 99a4927f8a | |||
| df5b8dcdcd | |||
| d4a1a2e64d | |||
| 5e2df269a3 | |||
| a2056e0441 | |||
| a6bbd8f8fb | |||
| 9f4e0cf5f8 | |||
| c7e8365d3f | |||
| 49b10b490e | |||
| f26bca8627 | |||
| df139b3d34 | |||
| 05d1edf278 | |||
| a518df8ff0 | |||
| 0a13fe4f40 | |||
| 6410bd1085 | |||
| 55c4549cff | |||
| e898948073 | |||
| 5f1c8d8ac0 | |||
| 8d1f8f2355 | |||
| fbbbad608b | |||
| 03fc4abd2a | |||
| f280a37818 | |||
| f100ec62d4 | |||
| cd827612d9 | |||
| 50da2f82c8 | |||
| 4c814ec5f4 | |||
| fb8c02d5ab | |||
| 465ad3d1ad | |||
| 2bfaab7d16 | |||
| e356542df7 | |||
| a3dfbdce3b | |||
| a666e8d248 | |||
| 9ef8df22a8 | |||
| a1471c8b9a | |||
| a99a14922c | |||
| ce954ad775 | |||
| efba7c10d0 | |||
| cd10c131b2 | |||
| 3e4cb8dfa2 | |||
| 6471ab547c | |||
| c10bf07124 | |||
| 4e05882522 | |||
| 061b292d92 | |||
| 779a6c309a | |||
| c79c71f209 | |||
| d4dbce5606 | |||
| e53d99223d | |||
| ec7274e0c9 | |||
| 8fe631a98e | |||
| bd831bd92b | |||
| 9b31ba8f3d | |||
| 3759be333a | |||
| 555894b26c | |||
| bb636c003c | |||
| 40c7b4aebd | |||
| 028bd4888b | |||
| 27d64e361a | |||
| 6fc7dd49dd | |||
| b835ba1d65 | |||
| 811e985087 | |||
| c16733b93d | |||
| 23ab3c5198 | |||
| 9b8f121e69 | |||
| 86b1b95fb9 | |||
| 9418681a00 | |||
| 7ebd937e2c | |||
| 25c404f999 | |||
| 6df92bde2a | |||
| 60ff006fec | |||
| 0181f5e10c | |||
| 4bbcc60bbb | |||
| 8e2138e2aa | |||
| 70b9e8c10d | |||
| dc1eddb0df | |||
| 630551cc4b | |||
| 4f94b25182 | |||
| d3b240eae3 | |||
| e2883d6727 | |||
| 21757b37ab | |||
| 9190857ffa | |||
| a607443aa7 | |||
| 41969bc0c2 | |||
| 0d443afb87 | |||
| 3d85294d41 | |||
| 86896771a6 | |||
| 42d52990fa | |||
| 6296c8257f | |||
| 14a40cb1b6 | |||
| d0e3650de5 | |||
| df8ed666ae | |||
| 2217b7f800 | |||
| 924febc2f3 | |||
| 729ffb7952 | |||
| 1568ca7633 | |||
| 7d5e476580 | |||
| 9bcd187358 | |||
| a9cbb6272f | |||
| b524decfa7 | |||
| 5ee01f7735 | |||
| f46f2d6523 | |||
| 435dbf6cee | |||
| 94d38eeb71 | |||
| 0e7f6f90ed | |||
| f3789287dc | |||
| a8312d7c10 | |||
| 612f4a7978 | |||
| 2462347b5f | |||
| 3ec3c0d76b | |||
| 8224aa98f4 | |||
| bb9d9422fb | |||
| f3a184ff00 | |||
| 866e4586df | |||
| a30647893d | |||
| 73826312e5 | |||
| dae34dc0ef | |||
| 17d0c8c26f | |||
| 67831dc162 | |||
| ae84fbe141 | |||
| 8c9bd890e8 | |||
| e8c1956de5 | |||
| 1540a41d3a | |||
| 9cb61cb696 | |||
| 3511325285 | |||
| 62507c5d28 | |||
| 387df29a05 | |||
| 75dca7e1ad | |||
| 2d5e54d5ad | |||
| d62b2b0d6f | |||
| e273719ad1 | |||
| f6726f1c94 | |||
| 92ff4f661f | |||
| dfebc5e608 | |||
| 8e6121cd0b | |||
| 6c0d5714bd | |||
| ed29697434 | |||
| 07aebef82f | |||
| 7ce390fd50 | |||
| cebe392d1c | |||
| fd160f585a | |||
| 5c0516fbc0 | |||
| ba075326c1 | |||
| c32425341e | |||
| cc8df1eff3 | |||
| 49cda0791c | |||
| 60ea363a62 | |||
| f66619b225 | |||
| 5999402e1a | |||
| 3aebccc090 | |||
| ed577dd51a | |||
| e517487596 | |||
| f9441909a4 | |||
| f13a015bfa | |||
| 18bf946464 | |||
| e4c46b2488 | |||
| e0539b116c | |||
| acdf7af1d9 | |||
| d5b7828079 | |||
| 03be071edc | |||
| e8742f30a6 | |||
| 3d7727dbf2 | |||
| 5bff67860f | |||
| 429e967da2 | |||
| c3df3beeae | |||
| 4d664c15d7 | |||
| 53be365e38 | |||
| 3c3ffed32e | |||
| 015726b3a6 | |||
| 4a3267712b | |||
| 9b45aae410 | |||
| f43b391d0e | |||
| b09b7a0c84 | |||
| 541b44b933 | |||
| b21fce0eff | |||
| 017aa27c0f | |||
| 496978e8a2 | |||
| b3711a590d | |||
| 631a0f2c4c | |||
| 503f7ca455 | |||
| 80c771b7bc | |||
| 6e344d350b | |||
| 7738bc83af | |||
| 6f7a1eecf6 | |||
| bf147a736a | |||
| ea3decf1ac | |||
| b1c0315a6e | |||
| f6fa7e8f41 | |||
| 33d4d7994c | |||
| 0ce4ce2b08 | |||
| 3a9163e919 | |||
| 376cff748b | |||
| d5cef080da | |||
| 33e31213b3 | |||
| 8077ba0c35 | |||
| 67ad3847f8 | |||
| 1eda042b34 | |||
| 961ff46f43 | |||
| 535ec34a1b | |||
| fe474fd0bf | |||
| bfc737c1d3 | |||
| ce0eb32d4e | |||
| 090df2e3ea | |||
| 01d9df2867 | |||
| 334a14c80c | |||
| 3d952570af | |||
| bf9567fe6f | |||
| 6c4c39b454 | |||
| d39eb3f79f | |||
| 7f0ce61126 | |||
| 775056da6f | |||
| a15ecb375b | |||
| 3dea941c51 | |||
| 410f0aba95 | |||
| 1ec13fbf63 | |||
| 012408b7cd | |||
| fdca75b153 | |||
| 40c235cdac | |||
| 7ea8fa735c | |||
| 758141dbc4 | |||
| 3c37fa45e6 | |||
| 35a80f4585 | |||
| 57e033c7c6 | |||
| d05d070faa | |||
| 3f3829a745 | |||
| 21bb89e5ea | |||
| fad5ecbb89 | |||
| 2c64c57edd | |||
| 117c637756 | |||
| 488f161322 | |||
| a55ef684e6 | |||
| 90234ba01e | |||
| cd83361050 | |||
| 08cda481ba | |||
| 45f998d134 | |||
| 33ee5b4ec4 | |||
| 545bbaafa5 | |||
| cc957460bb | |||
| cd51dab845 | |||
| 74923515cc | |||
| b3f3c415df | |||
| 0b9de65cbc | |||
| 1c26171b52 | |||
| a6bc0afdd3 | |||
| 966cfe6565 | |||
| d5b1c5805e | |||
| a4ce5ff9ca | |||
| 8e4b70578b | |||
| 5367874062 | |||
| 321696b5b5 | |||
| b642a5550c | |||
| 0e97632099 | |||
| 4a3c6c4124 | |||
| 3bf9c143d4 | |||
| 285e5e256e | |||
| 5b06b89d7a | |||
| 7e749fa096 | |||
| 13ac3f0e74 | |||
| 32c551999c | |||
| ff2c09d3be | |||
| 4c97ac4497 | |||
| 86516186d9 | |||
| e0581bdc3c |
@@ -1,98 +1,218 @@
|
||||
# TELEGRAM BOT CONFIGURATION
|
||||
# ===============================================
|
||||
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
|
||||
# ===============================================
|
||||
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
SUPPORT_USERNAME=
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# DATABASE CONFIGURATION
|
||||
DATABASE_URL=sqlite+aiosqlite:///./bot.db
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# REMNAWAVE API CONFIGURATION
|
||||
REMNAWAVE_API_URL=
|
||||
# ===== DATABASE =====
|
||||
# Для Docker используйте PostgreSQL:
|
||||
DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
# Для локального запуска без Docker используйте SQLite: sqlite+aiosqlite:///./bot.db
|
||||
# DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Пароли для Docker (PostgreSQL/Redis)
|
||||
POSTGRES_DB=remnawave_bot
|
||||
POSTGRES_USER=remnawave_user
|
||||
POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=your_secret_key_here
|
||||
|
||||
# === NEW: Traffic Selection Mode Settings ===
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
# Максимум устройств достопных к покупке (0 = Нет лимита)
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
# "selectable" - пользователи выбирают пакеты трафика (по умолчанию)
|
||||
# "fixed" - фиксированный лимит трафика для всех подписок, доступно 5/10/25/50/100/250/0 (0 безлимит) гб
|
||||
# "fixed" - фиксированный лимит трафика для всех подписок
|
||||
TRAFFIC_SELECTION_MODE=selectable
|
||||
|
||||
# Фиксированный лимит трафика в ГБ (используется только в режиме "fixed")
|
||||
# 0 = безлимит
|
||||
# для "fixed" обязательно должы быть проставлены цены на пакеты 5/10/25/50/100/250/0 можно постать 0 руб - будет беслпатно
|
||||
FIXED_TRAFFIC_LIMIT_GB=0
|
||||
FIXED_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# TRIAL SUBSCRIPTION SETTINGS
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=2
|
||||
TRIAL_SQUAD_UUID=
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
# ===== ПЕРИОДЫ ПОДПИСКИ =====
|
||||
# Доступные периоды подписки (через запятую)
|
||||
# Возможные значения: 14,30,60,90,180,360
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# SUBSCRIPTION PRICING (в копейках для точности)
|
||||
BASE_SUBSCRIPTION_PRICE=50000
|
||||
# ===== ЦЕНЫ (в копейках) =====
|
||||
BASE_SUBSCRIPTION_PRICE=0
|
||||
|
||||
PRICE_14_DAYS=5000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_60_DAYS=18900
|
||||
PRICE_90_DAYS=26900
|
||||
PRICE_180_DAYS=49900
|
||||
PRICE_360_DAYS=89900
|
||||
# Цены за периоды
|
||||
PRICE_14_DAYS=7000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_60_DAYS=25900
|
||||
PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
PRICE_TRAFFIC_5GB=2000
|
||||
PRICE_TRAFFIC_10GB=4000
|
||||
PRICE_TRAFFIC_25GB=6000
|
||||
PRICE_TRAFFIC_50GB=10000
|
||||
PRICE_TRAFFIC_100GB=15000
|
||||
PRICE_TRAFFIC_250GB=20000
|
||||
PRICE_TRAFFIC_UNLIMITED=25000
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=5000
|
||||
|
||||
# REFERRAL SYSTEM SETTINGS
|
||||
REFERRAL_REGISTRATION_REWARD=5000
|
||||
REFERRED_USER_REWARD=10000
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
YOOKASSA_ENABLED=false
|
||||
YOOKASSA_SHOP_ID=
|
||||
YOOKASSA_SECRET_KEY=
|
||||
YOOKASSA_RETURN_URL=
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
|
||||
|
||||
# Настройки чеков для налоговой
|
||||
YOOKASSA_VAT_CODE=1
|
||||
# Коды НДС:
|
||||
# 1 - НДС не облагается
|
||||
# 2 - НДС 0%
|
||||
# 3 - НДС 10%
|
||||
# 4 - НДС 20%
|
||||
# 5 - НДС 10/110
|
||||
# 6 - НДС 20/120
|
||||
|
||||
YOOKASSA_PAYMENT_MODE=full_payment
|
||||
# Способы расчета:
|
||||
# full_payment - полная оплата
|
||||
# partial_payment - частичная оплата
|
||||
# advance - аванс
|
||||
# full_prepayment - полная предоплата
|
||||
# partial_prepayment - частичная предоплата
|
||||
# credit - передача в кредит
|
||||
# credit_payment - оплата кредита
|
||||
|
||||
YOOKASSA_PAYMENT_SUBJECT=service
|
||||
# Предметы расчета:
|
||||
# commodity - товар
|
||||
# excise - подакцизный товар
|
||||
# job - работа
|
||||
# service - услуга
|
||||
# gambling_bet - ставка в азартной игре
|
||||
# gambling_prize - выигрыш в азартной игре
|
||||
# lottery - лотерейный билет
|
||||
# lottery_prize - выигрыш в лотерее
|
||||
# intellectual_activity - результат интеллектуальной деятельности
|
||||
# payment - платеж
|
||||
# agent_commission - агентское вознаграждение
|
||||
# composite - составной предмет расчета
|
||||
# another - другое
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
|
||||
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
CONNECT_BUTTON_MODE=miniapp_subscription
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
# MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# AUTO-PAYMENT SETTINGS
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
|
||||
# MONITORING SETTINGS
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
MONITORING_INTERVAL=60
|
||||
INACTIVE_USER_DELETE_MONTHS=3
|
||||
|
||||
# Уведомления
|
||||
TRIAL_WARNING_HOURS=2
|
||||
ENABLE_NOTIFICATIONS=true
|
||||
NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# PAYMENT SYSTEMS
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
|
||||
TRIBUTE_DONATE_LINK=https://t.me/tribute/app?startapp=XXXX
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
WEBHOOK_URL=https://example.com
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
# LOCALIZATION
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru
|
||||
|
||||
# LOGGING
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=/tmp/bot.log
|
||||
|
||||
# DEVELOPMENT
|
||||
DEBUG=false
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
@@ -4,12 +4,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
@@ -42,6 +44,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
|
||||
@@ -1,2 +1,30 @@
|
||||
/screens
|
||||
/assets
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.env.example
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
!LICENSE
|
||||
|
||||
# Разрешаем .gitignore чтобы он попал в репозиторий
|
||||
!.gitignore
|
||||
|
||||
# Внутри разрешенных папок игнорируем служебные файлы
|
||||
app/__pycache__/
|
||||
app/**/__pycache__/
|
||||
app/**/*.pyc
|
||||
app/**/*.pyo
|
||||
app/**/*.pyd
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Create non-root user with specific UID/GID for consistency
|
||||
RUN groupadd -g 1000 app && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash app
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create logs directory
|
||||
RUN mkdir -p logs
|
||||
# Create necessary directories and set permissions
|
||||
RUN mkdir -p logs data && \
|
||||
chown -R app:app /app && \
|
||||
chmod -R 755 /app && \
|
||||
chmod -R 777 logs
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd --create-home --shell /bin/bash app
|
||||
RUN chown -R app:app /app
|
||||
USER app
|
||||
# Expose webhook ports
|
||||
EXPOSE 8081
|
||||
EXPOSE 8082
|
||||
|
||||
# Expose port (if needed for webhooks)
|
||||
EXPOSE 8000
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -9,6 +9,8 @@ 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.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
@@ -20,8 +22,10 @@ from app.handlers.admin import (
|
||||
promocodes as admin_promocodes, messages as admin_messages,
|
||||
monitoring as admin_monitoring, referrals as admin_referrals,
|
||||
rules as admin_rules, remnawave as admin_remnawave,
|
||||
statistics as admin_statistics, servers as admin_servers
|
||||
statistics as admin_statistics, servers as admin_servers,
|
||||
maintenance as admin_maintenance
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,9 +41,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
try:
|
||||
await cache.connect()
|
||||
logger.info("✅ Кеш инициализирован")
|
||||
logger.info("Кеш инициализирован")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Кеш не инициализирован: {e}")
|
||||
logger.warning(f"Кеш не инициализирован: {e}")
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -49,28 +53,31 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info("Бот установлен в maintenance_service")
|
||||
|
||||
try:
|
||||
redis_client = redis.from_url(settings.REDIS_URL)
|
||||
await redis_client.ping()
|
||||
storage = RedisStorage(redis_client)
|
||||
logger.info("✅ Подключено к Redis для FSM storage")
|
||||
logger.info("Подключено к Redis для FSM storage")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}")
|
||||
logger.info("🔄 Используется MemoryStorage для FSM")
|
||||
logger.warning(f"Не удалось подключиться к Redis: {e}")
|
||||
logger.info("Используется MemoryStorage для FSM")
|
||||
storage = MemoryStorage()
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
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)
|
||||
subscription.register_handlers(dp)
|
||||
@@ -78,7 +85,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
@@ -90,9 +96,33 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
|
||||
common.register_handlers(dp)
|
||||
|
||||
logger.info("✅ Бот успешно настроен")
|
||||
register_stars_handlers(dp)
|
||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
return bot, dp
|
||||
|
||||
|
||||
async def shutdown_bot():
|
||||
try:
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info("Мониторинг техработ остановлен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки мониторинга: {e}")
|
||||
|
||||
try:
|
||||
await cache.close()
|
||||
logger.info("Соединения с кешем закрыты")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка закрытия кеша: {e}")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from typing import List, Optional, Union
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
@@ -10,18 +10,26 @@ class Settings(BaseSettings):
|
||||
BOT_TOKEN: str
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
|
||||
DATABASE_URL: str
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
REMNAWAVE_API_URL: str
|
||||
REMNAWAVE_API_KEY: str
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: str
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
ENABLE_NOTIFICATIONS: bool = True
|
||||
@@ -31,7 +39,9 @@ class Settings(BaseSettings):
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
|
||||
BASE_SUBSCRIPTION_PRICE: int = 50000
|
||||
|
||||
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
|
||||
AVAILABLE_RENEWAL_PERIODS: str = "30,90,180"
|
||||
PRICE_14_DAYS: int = 50000
|
||||
PRICE_30_DAYS: int = 99000
|
||||
PRICE_60_DAYS: int = 189000
|
||||
@@ -39,22 +49,31 @@ class Settings(BaseSettings):
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 10000
|
||||
PRICE_TRAFFIC_10GB: int = 19000
|
||||
PRICE_TRAFFIC_25GB: int = 45000
|
||||
PRICE_TRAFFIC_50GB: int = 85000
|
||||
PRICE_TRAFFIC_100GB: int = 159000
|
||||
PRICE_TRAFFIC_250GB: int = 369000
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 0
|
||||
PRICE_TRAFFIC_5GB: int = 2000
|
||||
PRICE_TRAFFIC_10GB: int = 3500
|
||||
PRICE_TRAFFIC_25GB: int = 7000
|
||||
PRICE_TRAFFIC_50GB: int = 11000
|
||||
PRICE_TRAFFIC_100GB: int = 15000
|
||||
PRICE_TRAFFIC_250GB: int = 17000
|
||||
PRICE_TRAFFIC_500GB: int = 19000
|
||||
PRICE_TRAFFIC_1000GB: int = 19500
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
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
|
||||
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
|
||||
REFERRAL_COMMISSION_PERCENT: int = 25
|
||||
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
REFERRED_USER_REWARD: int = 0
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
@@ -63,8 +82,14 @@ class Settings(BaseSettings):
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
TELEGRAM_STARS_RATE_RUB: float = 1.3
|
||||
|
||||
TRIBUTE_ENABLED: bool = False
|
||||
TRIBUTE_API_KEY: Optional[str] = None
|
||||
@@ -73,6 +98,24 @@ class Settings(BaseSettings):
|
||||
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
|
||||
TRIBUTE_WEBHOOK_PORT: int = 8081
|
||||
|
||||
YOOKASSA_ENABLED: bool = False
|
||||
YOOKASSA_SHOP_ID: Optional[str] = None
|
||||
YOOKASSA_SECRET_KEY: Optional[str] = None
|
||||
YOOKASSA_RETURN_URL: Optional[str] = None
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
|
||||
YOOKASSA_VAT_CODE: int = 1
|
||||
YOOKASSA_PAYMENT_MODE: str = "full_payment"
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = "service"
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
|
||||
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
|
||||
PAYMENT_BALANCE_TEMPLATE: str = "{service_name} - {description}"
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}"
|
||||
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
|
||||
@@ -174,6 +217,243 @@ class Settings(BaseSettings):
|
||||
def get_fixed_traffic_limit(self) -> int:
|
||||
return self.FIXED_TRAFFIC_LIMIT_GB
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
self.YOOKASSA_SHOP_ID is not None and
|
||||
self.YOOKASSA_SECRET_KEY is not None)
|
||||
|
||||
def get_yookassa_return_url(self) -> str:
|
||||
if self.YOOKASSA_RETURN_URL:
|
||||
return self.YOOKASSA_RETURN_URL
|
||||
elif self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-success"
|
||||
return "https://t.me/"
|
||||
|
||||
def is_maintenance_mode(self) -> bool:
|
||||
return self.MAINTENANCE_MODE
|
||||
|
||||
def get_maintenance_message(self) -> str:
|
||||
return self.MAINTENANCE_MESSAGE
|
||||
|
||||
def get_maintenance_check_interval(self) -> int:
|
||||
return self.MAINTENANCE_CHECK_INTERVAL
|
||||
|
||||
def is_maintenance_auto_enable(self) -> bool:
|
||||
return self.MAINTENANCE_AUTO_ENABLE
|
||||
|
||||
def get_available_subscription_periods(self) -> List[int]:
|
||||
try:
|
||||
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
|
||||
if not periods_str.strip():
|
||||
return [30, 90, 180]
|
||||
|
||||
periods = []
|
||||
for period_str in periods_str.split(','):
|
||||
period_str = period_str.strip()
|
||||
if period_str:
|
||||
period = int(period_str)
|
||||
if period in PERIOD_PRICES:
|
||||
periods.append(period)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
|
||||
def get_available_renewal_periods(self) -> List[int]:
|
||||
try:
|
||||
periods_str = self.AVAILABLE_RENEWAL_PERIODS
|
||||
if not periods_str.strip():
|
||||
return [30, 90, 180]
|
||||
|
||||
periods = []
|
||||
for period_str in periods_str.split(','):
|
||||
period_str = period_str.strip()
|
||||
if period_str:
|
||||
period = int(period_str)
|
||||
if period in PERIOD_PRICES:
|
||||
periods.append(period)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
|
||||
def get_balance_payment_description(self, amount_kopeks: int) -> str:
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
|
||||
)
|
||||
|
||||
def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str:
|
||||
return self.PAYMENT_SUBSCRIPTION_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=f"{self.PAYMENT_SUBSCRIPTION_DESCRIPTION} на {period_days} дней"
|
||||
)
|
||||
|
||||
def get_custom_payment_description(self, description: str) -> str:
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=description
|
||||
)
|
||||
|
||||
def get_stars_rate(self) -> float:
|
||||
return self.TELEGRAM_STARS_RATE_RUB
|
||||
|
||||
def stars_to_rubles(self, stars: int) -> float:
|
||||
return stars * self.get_stars_rate()
|
||||
|
||||
def rubles_to_stars(self, rubles: float) -> int:
|
||||
return max(1, int(rubles / self.get_stars_rate()))
|
||||
|
||||
def get_admin_notifications_chat_id(self) -> Optional[int]:
|
||||
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(self.ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def is_admin_notifications_enabled(self) -> bool:
|
||||
return (self.ADMIN_NOTIFICATIONS_ENABLED and
|
||||
self.get_admin_notifications_chat_id() is not None)
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
return {
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": self.REFERRED_USER_REWARD
|
||||
}
|
||||
|
||||
def is_referral_notifications_enabled(self) -> bool:
|
||||
return self.REFERRAL_NOTIFICATIONS_ENABLED
|
||||
|
||||
def get_traffic_packages(self) -> List[Dict]:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
packages = []
|
||||
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
||||
|
||||
logger.info(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
if not config_str:
|
||||
logger.info("CONFIG EMPTY, USING FALLBACK")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
logger.info("PARSING CONFIG...")
|
||||
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true'
|
||||
|
||||
packages.append({
|
||||
"gb": gb,
|
||||
"price": price,
|
||||
"enabled": enabled
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.info(f"PARSED {len(packages)} packages from config")
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"ERROR PARSING CONFIG: {e}")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
def _get_fallback_traffic_packages(self) -> List[Dict]:
|
||||
try:
|
||||
if self.TRAFFIC_PACKAGES_CONFIG.strip():
|
||||
packages = []
|
||||
for package_config in self.TRAFFIC_PACKAGES_CONFIG.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true'
|
||||
|
||||
packages.append({
|
||||
"gb": gb,
|
||||
"price": price,
|
||||
"enabled": enabled
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if packages:
|
||||
return packages
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return [
|
||||
{"gb": 5, "price": self.PRICE_TRAFFIC_5GB, "enabled": True},
|
||||
{"gb": 10, "price": self.PRICE_TRAFFIC_10GB, "enabled": True},
|
||||
{"gb": 25, "price": self.PRICE_TRAFFIC_25GB, "enabled": True},
|
||||
{"gb": 50, "price": self.PRICE_TRAFFIC_50GB, "enabled": True},
|
||||
{"gb": 100, "price": self.PRICE_TRAFFIC_100GB, "enabled": True},
|
||||
{"gb": 250, "price": self.PRICE_TRAFFIC_250GB, "enabled": True},
|
||||
{"gb": 500, "price": self.PRICE_TRAFFIC_500GB, "enabled": True},
|
||||
{"gb": 1000, "price": self.PRICE_TRAFFIC_1000GB, "enabled": True},
|
||||
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
|
||||
]
|
||||
|
||||
def get_traffic_price(self, gb: int) -> int:
|
||||
packages = self.get_traffic_packages()
|
||||
|
||||
for package in packages:
|
||||
if package["gb"] == gb and package["enabled"]:
|
||||
return package["price"]
|
||||
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
|
||||
|
||||
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
|
||||
if finite_packages:
|
||||
max_package = max(finite_packages, key=lambda x: x["gb"])
|
||||
|
||||
if gb > max_package["gb"]:
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
else:
|
||||
return max_package["price"]
|
||||
|
||||
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
|
||||
if suitable_packages:
|
||||
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
|
||||
return nearest_package["price"]
|
||||
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
|
||||
return 0
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8"
|
||||
@@ -192,12 +472,14 @@ PERIOD_PRICES = {
|
||||
360: settings.PRICE_360_DAYS,
|
||||
}
|
||||
|
||||
TRAFFIC_PRICES = {
|
||||
5: settings.PRICE_TRAFFIC_5GB,
|
||||
10: settings.PRICE_TRAFFIC_10GB,
|
||||
25: settings.PRICE_TRAFFIC_25GB,
|
||||
50: settings.PRICE_TRAFFIC_50GB,
|
||||
100: settings.PRICE_TRAFFIC_100GB,
|
||||
250: settings.PRICE_TRAFFIC_250GB,
|
||||
0: settings.PRICE_TRAFFIC_UNLIMITED,
|
||||
}
|
||||
def get_traffic_prices() -> Dict[int, int]:
|
||||
packages = settings.get_traffic_packages()
|
||||
return {package["gb"]: package["price"] for package in packages}
|
||||
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
def refresh_traffic_prices():
|
||||
global TRAFFIC_PRICES
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
refresh_traffic_prices()
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType
|
||||
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -187,8 +187,13 @@ async def update_promocode(
|
||||
|
||||
|
||||
async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
|
||||
|
||||
try:
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
await db.execute(
|
||||
sql_delete(PromoCodeUse).where(PromoCodeUse.promocode_id == promocode.id)
|
||||
)
|
||||
|
||||
await db.delete(promocode)
|
||||
await db.commit()
|
||||
|
||||
@@ -222,15 +227,24 @@ async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
today_uses = today_uses_result.scalar()
|
||||
|
||||
recent_uses_result = await db.execute(
|
||||
select(PromoCodeUse)
|
||||
select(PromoCodeUse, User)
|
||||
.join(User, PromoCodeUse.user_id == User.id)
|
||||
.where(PromoCodeUse.promocode_id == promocode_id)
|
||||
.order_by(PromoCodeUse.used_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
recent_uses = recent_uses_result.scalars().all()
|
||||
recent_uses_data = recent_uses_result.all()
|
||||
|
||||
recent_uses = []
|
||||
for use, user in recent_uses_data:
|
||||
use.user_username = user.username
|
||||
use.user_full_name = user.full_name
|
||||
use.user_telegram_id = user.telegram_id
|
||||
recent_uses.append(use)
|
||||
|
||||
return {
|
||||
"total_uses": total_uses,
|
||||
"today_uses": today_uses,
|
||||
"recent_uses": recent_uses
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ async def get_referral_earnings_sum(
|
||||
|
||||
|
||||
async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
users_with_referrals_result = await db.execute(
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
@@ -117,41 +118,72 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_paid = referral_paid + transaction_paid
|
||||
|
||||
top_referrers_result = await db.execute(
|
||||
referrals_stats_result = await db.execute(
|
||||
select(
|
||||
User.referred_by_id.label('referrer_id'),
|
||||
func.count(User.id).label('referrals_count'),
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('referral_earnings'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('transaction_earnings')
|
||||
)
|
||||
.outerjoin(
|
||||
ReferralEarning,
|
||||
ReferralEarning.user_id == User.referred_by_id
|
||||
)
|
||||
.outerjoin(
|
||||
Transaction,
|
||||
and_(
|
||||
Transaction.user_id == User.referred_by_id,
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.referred_by_id != User.id
|
||||
)
|
||||
func.count(User.id).label('referrals_count')
|
||||
)
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
.group_by(User.referred_by_id)
|
||||
.order_by(func.count(User.id).desc())
|
||||
.limit(5)
|
||||
)
|
||||
top_referrers_raw = top_referrers_result.all()
|
||||
referrals_stats = {row.referrer_id: row.referrals_count for row in referrals_stats_result.all()}
|
||||
|
||||
referral_earnings_result = await db.execute(
|
||||
select(
|
||||
ReferralEarning.user_id.label('referrer_id'),
|
||||
func.sum(ReferralEarning.amount_kopeks).label('referral_earnings')
|
||||
)
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
referral_earnings = {row.referrer_id: row.referral_earnings for row in referral_earnings_result.all()}
|
||||
|
||||
transaction_earnings_result = await db.execute(
|
||||
select(
|
||||
Transaction.user_id.label('referrer_id'),
|
||||
func.sum(Transaction.amount_kopeks).label('transaction_earnings')
|
||||
)
|
||||
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
transaction_earnings = {row.referrer_id: row.transaction_earnings for row in transaction_earnings_result.all()}
|
||||
|
||||
top_referrers_data = {}
|
||||
|
||||
for referrer_id, count in referrals_stats.items():
|
||||
if referrer_id not in top_referrers_data:
|
||||
top_referrers_data[referrer_id] = {
|
||||
'referrals_count': 0,
|
||||
'total_earned': 0
|
||||
}
|
||||
top_referrers_data[referrer_id]['referrals_count'] = count
|
||||
|
||||
for referrer_id, earnings in referral_earnings.items():
|
||||
if referrer_id not in top_referrers_data:
|
||||
top_referrers_data[referrer_id] = {
|
||||
'referrals_count': 0,
|
||||
'total_earned': 0
|
||||
}
|
||||
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
|
||||
|
||||
for referrer_id, earnings in transaction_earnings.items():
|
||||
if referrer_id not in top_referrers_data:
|
||||
top_referrers_data[referrer_id] = {
|
||||
'referrals_count': 0,
|
||||
'total_earned': 0
|
||||
}
|
||||
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
|
||||
|
||||
sorted_referrers = sorted(
|
||||
top_referrers_data.items(),
|
||||
key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
top_referrers = []
|
||||
for row in top_referrers_raw:
|
||||
for referrer_id, stats in sorted_referrers[:5]:
|
||||
user_result = await db.execute(
|
||||
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
|
||||
.where(User.id == row.referrer_id)
|
||||
.where(User.id == referrer_id)
|
||||
)
|
||||
user = user_result.first()
|
||||
|
||||
@@ -166,23 +198,22 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
else:
|
||||
display_name = f"ID{user.telegram_id}"
|
||||
|
||||
total_earned = (row.referral_earnings or 0) + (row.transaction_earnings or 0)
|
||||
|
||||
top_referrers.append({
|
||||
"user_id": row.referrer_id,
|
||||
"user_id": user.telegram_id,
|
||||
"display_name": display_name,
|
||||
"username": user.username,
|
||||
"total_earned_kopeks": total_earned,
|
||||
"referrals_count": row.referrals_count
|
||||
"telegram_id": user.telegram_id,
|
||||
"total_earned_kopeks": stats['total_earned'],
|
||||
"referrals_count": stats['referrals_count']
|
||||
})
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
today_referral_earnings = await db.execute(
|
||||
today_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= today)
|
||||
)
|
||||
today_transaction_earnings = await db.execute(
|
||||
today_transaction_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
@@ -191,14 +222,14 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
)
|
||||
)
|
||||
today_earnings = today_referral_earnings.scalar() + today_transaction_earnings.scalar()
|
||||
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_referral_earnings = await db.execute(
|
||||
week_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= week_ago)
|
||||
)
|
||||
week_transaction_earnings = await db.execute(
|
||||
week_transaction_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
@@ -207,14 +238,14 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
)
|
||||
)
|
||||
week_earnings = week_referral_earnings.scalar() + week_transaction_earnings.scalar()
|
||||
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_referral_earnings = await db.execute(
|
||||
month_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= month_ago)
|
||||
)
|
||||
month_transaction_earnings = await db.execute(
|
||||
month_transaction_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
@@ -223,7 +254,9 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
)
|
||||
)
|
||||
month_earnings = month_referral_earnings.scalar() + month_transaction_earnings.scalar()
|
||||
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
|
||||
|
||||
logger.info(f"Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек")
|
||||
|
||||
return {
|
||||
"users_with_referrals": users_with_referrals,
|
||||
@@ -248,13 +281,17 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
|
||||
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
active_referrals_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.join(User.subscription)
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
User.subscription.has()
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > current_time
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -265,4 +302,4 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
"active_referrals": active_referrals,
|
||||
"total_earned_kopeks": total_earned,
|
||||
"month_earned_kopeks": month_earned
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
)
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,6 +20,8 @@ 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)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
@@ -98,17 +101,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
|
||||
|
||||
|
||||
@@ -338,6 +354,37 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
purchased_month = month_result.scalar()
|
||||
|
||||
try:
|
||||
from app.database.crud.subscription_conversion import get_conversion_statistics
|
||||
conversion_stats = await get_conversion_statistics(db)
|
||||
|
||||
trial_to_paid_conversion = conversion_stats.get("conversion_rate", 0)
|
||||
renewals_count = conversion_stats.get("month_conversions", 0)
|
||||
|
||||
logger.info(f"📊 Статистика конверсии из таблицы conversions:")
|
||||
logger.info(f" Общее количество конверсий: {conversion_stats.get('total_conversions', 0)}")
|
||||
logger.info(f" Процент конверсии: {trial_to_paid_conversion}%")
|
||||
logger.info(f" Конверсий за месяц: {renewals_count}")
|
||||
|
||||
except ImportError:
|
||||
logger.warning("⚠️ Таблица subscription_conversions не найдена, используем старую логику")
|
||||
|
||||
users_with_paid_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.has_had_paid_subscription == True)
|
||||
)
|
||||
users_with_paid = users_with_paid_result.scalar()
|
||||
|
||||
total_users_result = await db.execute(select(func.count(User.id)))
|
||||
total_users = total_users_result.scalar()
|
||||
|
||||
if total_users > 0:
|
||||
trial_to_paid_conversion = round((users_with_paid / total_users) * 100, 1)
|
||||
else:
|
||||
trial_to_paid_conversion = 0
|
||||
|
||||
renewals_count = 0
|
||||
|
||||
return {
|
||||
"total_subscriptions": total_subscriptions,
|
||||
"active_subscriptions": active_subscriptions,
|
||||
@@ -345,10 +392,11 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
"paid_subscriptions": paid_subscriptions,
|
||||
"purchased_today": purchased_today,
|
||||
"purchased_week": purchased_week,
|
||||
"purchased_month": purchased_month
|
||||
"purchased_month": purchased_month,
|
||||
"trial_to_paid_conversion": trial_to_paid_conversion,
|
||||
"renewals_count": renewals_count
|
||||
}
|
||||
|
||||
|
||||
async def update_subscription_usage(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
@@ -392,9 +440,19 @@ async def add_subscription_servers(
|
||||
server_squad_ids: List[int],
|
||||
paid_prices: List[int] = None
|
||||
) -> Subscription:
|
||||
|
||||
if paid_prices is None:
|
||||
paid_prices = [0] * len(server_squad_ids)
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
paid_prices = []
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in server_squad_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks)
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_price_per_month = result.scalar() or 0
|
||||
total_price_for_period = server_price_per_month * months_remaining
|
||||
paid_prices.append(total_price_for_period)
|
||||
|
||||
for i, server_id in enumerate(server_squad_ids):
|
||||
subscription_server = SubscriptionServer(
|
||||
@@ -407,9 +465,79 @@ async def add_subscription_servers(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов")
|
||||
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
|
||||
return subscription
|
||||
|
||||
async def get_server_monthly_price(
|
||||
db: AsyncSession,
|
||||
server_squad_id: int
|
||||
) -> int:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks)
|
||||
.where(ServerSquad.id == server_squad_id)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_servers_monthly_prices(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
) -> List[int]:
|
||||
prices = []
|
||||
for server_id in server_squad_ids:
|
||||
price = await get_server_monthly_price(db, server_id)
|
||||
prices.append(price)
|
||||
return prices
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int
|
||||
) -> Tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [price * months_in_period for price in servers_prices]
|
||||
}
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
|
||||
async def get_subscription_server_ids(
|
||||
db: AsyncSession,
|
||||
subscription_id: int
|
||||
@@ -482,30 +610,43 @@ async def get_subscription_renewal_cost(
|
||||
period_days: int
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES, TRAFFIC_PRICES, settings
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
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)
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
from app.database.models import ServerSquad
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks)
|
||||
.where(ServerSquad.id == server_info['server_id'])
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
devices_cost = max(0, subscription.device_limit - 1) * settings.PRICE_PER_DEVICE
|
||||
total_servers_cost = servers_price_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + servers_cost + traffic_cost + devices_cost
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_cost = traffic_price_per_month * months_in_period
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней:")
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_cost = devices_price_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
|
||||
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" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
@@ -515,6 +656,49 @@ async def get_subscription_renewal_cost(
|
||||
from app.config import PERIOD_PRICES
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
async def calculate_addon_cost_for_remaining_period(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
total_cost = 0
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_total_cost = traffic_price_per_month * months_to_pay
|
||||
total_cost += traffic_total_cost
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽")
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_cost = devices_price_per_month * months_to_pay
|
||||
total_cost += devices_total_cost
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽")
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in additional_server_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks, ServerSquad.display_name)
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
server_total_cost = server_price_per_month * months_to_pay
|
||||
total_cost += server_total_cost
|
||||
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽")
|
||||
|
||||
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽")
|
||||
return total_cost
|
||||
|
||||
async def expire_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SubscriptionConversion, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_subscription_conversion(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
trial_duration_days: int,
|
||||
payment_method: str,
|
||||
first_payment_amount_kopeks: int,
|
||||
first_paid_period_days: int
|
||||
) -> SubscriptionConversion:
|
||||
|
||||
conversion = SubscriptionConversion(
|
||||
user_id=user_id,
|
||||
converted_at=datetime.utcnow(),
|
||||
trial_duration_days=trial_duration_days,
|
||||
payment_method=payment_method,
|
||||
first_payment_amount_kopeks=first_payment_amount_kopeks,
|
||||
first_paid_period_days=first_paid_period_days
|
||||
)
|
||||
|
||||
db.add(conversion)
|
||||
await db.commit()
|
||||
await db.refresh(conversion)
|
||||
|
||||
logger.info(f"✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks/100}₽")
|
||||
|
||||
return conversion
|
||||
|
||||
|
||||
async def get_conversion_by_user_id(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[SubscriptionConversion]:
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionConversion)
|
||||
.where(SubscriptionConversion.user_id == user_id)
|
||||
.order_by(SubscriptionConversion.converted_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_conversion_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id))
|
||||
)
|
||||
total_conversions = total_conversions_result.scalar()
|
||||
|
||||
users_with_paid_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.has_had_paid_subscription == True)
|
||||
)
|
||||
users_with_paid = users_with_paid_result.scalar()
|
||||
|
||||
if total_conversions > 0:
|
||||
conversion_rate = round((total_conversions / max(total_conversions, users_with_paid)) * 100, 1)
|
||||
elif users_with_paid > 0:
|
||||
conversion_rate = 100.0
|
||||
else:
|
||||
conversion_rate = 0.0
|
||||
|
||||
avg_trial_duration_result = await db.execute(
|
||||
select(func.avg(SubscriptionConversion.trial_duration_days))
|
||||
)
|
||||
avg_trial_duration = avg_trial_duration_result.scalar() or 0
|
||||
|
||||
avg_first_payment_result = await db.execute(
|
||||
select(func.avg(SubscriptionConversion.first_payment_amount_kopeks))
|
||||
)
|
||||
avg_first_payment = avg_first_payment_result.scalar() or 0
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id))
|
||||
.where(SubscriptionConversion.converted_at >= month_ago)
|
||||
)
|
||||
month_conversions = month_conversions_result.scalar()
|
||||
|
||||
logger.info(f"📊 Статистика конверсий:")
|
||||
logger.info(f" Всего записей о конверсиях: {total_conversions}")
|
||||
logger.info(f" Пользователей с платными подписками: {users_with_paid}")
|
||||
logger.info(f" Рассчитанная конверсия: {conversion_rate}%")
|
||||
|
||||
return {
|
||||
"total_conversions": total_conversions,
|
||||
"conversion_rate": conversion_rate,
|
||||
"avg_trial_duration_days": round(avg_trial_duration, 1),
|
||||
"avg_first_payment_rubles": round((avg_first_payment or 0) / 100, 2),
|
||||
"month_conversions": month_conversions
|
||||
}
|
||||
|
||||
|
||||
async def get_users_had_trial_count(db: AsyncSession) -> int:
|
||||
|
||||
conversions_count_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id)))
|
||||
)
|
||||
conversions_count = conversions_count_result.scalar()
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.has_had_paid_subscription == True)
|
||||
)
|
||||
paid_users_count = paid_users_result.scalar()
|
||||
|
||||
return max(conversions_count, paid_users_count)
|
||||
@@ -275,4 +275,97 @@ async def get_revenue_by_period(
|
||||
.order_by(func.date(Transaction.created_at))
|
||||
)
|
||||
|
||||
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
|
||||
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
|
||||
|
||||
async def find_tribute_transactions_by_payment_id(
|
||||
db: AsyncSession,
|
||||
payment_id: str,
|
||||
user_telegram_id: Optional[int] = None
|
||||
) -> List[Transaction]:
|
||||
"""Найти все Tribute транзакции по payment_id"""
|
||||
|
||||
query = select(Transaction).options(selectinload(Transaction.user))
|
||||
|
||||
conditions = [
|
||||
Transaction.external_id == f"donation_{payment_id}",
|
||||
Transaction.external_id == payment_id,
|
||||
Transaction.external_id.like(f"%{payment_id}%")
|
||||
]
|
||||
|
||||
query = query.where(
|
||||
and_(
|
||||
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
|
||||
or_(*conditions)
|
||||
)
|
||||
)
|
||||
|
||||
if user_telegram_id:
|
||||
from app.database.models import User
|
||||
query = query.join(User).where(User.telegram_id == user_telegram_id)
|
||||
|
||||
result = await db.execute(query.order_by(Transaction.created_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def check_tribute_payment_duplicate(
|
||||
db: AsyncSession,
|
||||
payment_id: str,
|
||||
amount_kopeks: int,
|
||||
user_telegram_id: int
|
||||
) -> Optional[Transaction]:
|
||||
|
||||
transactions = await find_tribute_transactions_by_payment_id(
|
||||
db, payment_id, user_telegram_id
|
||||
)
|
||||
|
||||
for transaction in transactions:
|
||||
if (transaction.amount_kopeks == amount_kopeks and
|
||||
transaction.is_completed and
|
||||
transaction.user.telegram_id == user_telegram_id):
|
||||
return transaction
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def create_unique_tribute_transaction(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
payment_id: str,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
) -> Transaction:
|
||||
"""Создать уникальную Tribute транзакцию с защитой от дубликатов"""
|
||||
|
||||
external_id = f"donation_{payment_id}"
|
||||
|
||||
existing = await get_transaction_by_external_id(db, external_id, PaymentMethod.TRIBUTE)
|
||||
|
||||
if existing:
|
||||
timestamp = int(datetime.utcnow().timestamp())
|
||||
external_id = f"donation_{payment_id}_{amount_kopeks}_{timestamp}"
|
||||
|
||||
logger.info(f"Создан уникальный external_id для избежания дубликатов: {external_id}")
|
||||
|
||||
return await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=external_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
async def get_transaction_by_id(
|
||||
db: AsyncSession,
|
||||
transaction_id: int
|
||||
) -> Optional[Transaction]:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Transaction).where(Transaction.id == transaction_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения транзакции {transaction_id}: {e}")
|
||||
return None
|
||||
|
||||
@@ -125,29 +125,73 @@ async def add_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
bot = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.models import TransactionType
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
topup_keywords = ["пополнение", "stars", "yookassa", "topup"]
|
||||
exclude_keywords = ["комиссия", "бонус", "реферальн", "выплата", "вознаграждение"]
|
||||
|
||||
has_topup_keywords = any(word in description.lower() for word in topup_keywords)
|
||||
has_exclude_keywords = any(word in description.lower() for word in exclude_keywords)
|
||||
|
||||
if has_topup_keywords and not has_exclude_keywords:
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, amount_kopeks, bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def add_user_balance_by_id(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> User:
|
||||
|
||||
user.add_balance(amount_kopeks)
|
||||
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.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💰 Пополнен баланс пользователя {user.telegram_id}: +{amount_kopeks/100}₽")
|
||||
return user
|
||||
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
return await add_user_balance(db, user, amount_kopeks, description)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка пополнения баланса пользователя {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import YooKassaPayment, User, Transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_yookassa_payment(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
yookassa_payment_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: str,
|
||||
status: str,
|
||||
confirmation_url: Optional[str] = None,
|
||||
metadata_json: Optional[dict] = None,
|
||||
payment_method_type: Optional[str] = None,
|
||||
yookassa_created_at: Optional[datetime] = None,
|
||||
test_mode: bool = False
|
||||
) -> YooKassaPayment:
|
||||
|
||||
payment = YooKassaPayment(
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
confirmation_url=confirmation_url,
|
||||
metadata_json=metadata_json,
|
||||
payment_method_type=payment_method_type,
|
||||
yookassa_created_at=yookassa_created_at,
|
||||
test_mode=test_mode
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_yookassa_payment_by_id(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_id: str
|
||||
) -> Optional[YooKassaPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_yookassa_payment_by_local_id(
|
||||
db: AsyncSession,
|
||||
local_id: int
|
||||
) -> Optional[YooKassaPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(YooKassaPayment.id == local_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_yookassa_payment_status(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_id: str,
|
||||
status: str,
|
||||
is_paid: bool = False,
|
||||
is_captured: bool = False,
|
||||
captured_at: Optional[datetime] = None,
|
||||
payment_method_type: Optional[str] = None
|
||||
) -> Optional[YooKassaPayment]:
|
||||
|
||||
update_data = {
|
||||
"status": status,
|
||||
"is_paid": is_paid,
|
||||
"is_captured": is_captured,
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
if captured_at:
|
||||
update_data["captured_at"] = captured_at
|
||||
|
||||
if payment_method_type:
|
||||
update_data["payment_method_type"] = payment_method_type
|
||||
|
||||
await db.execute(
|
||||
update(YooKassaPayment)
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
)
|
||||
payment = result.scalar_one_or_none()
|
||||
|
||||
if payment:
|
||||
logger.info(f"Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}")
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_yookassa_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_id: str,
|
||||
transaction_id: int
|
||||
) -> Optional[YooKassaPayment]:
|
||||
|
||||
await db.execute(
|
||||
update(YooKassaPayment)
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
.values(transaction_id=transaction_id, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user), selectinload(YooKassaPayment.transaction))
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
)
|
||||
payment = result.scalar_one_or_none()
|
||||
|
||||
if payment:
|
||||
logger.info(f"Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}")
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_user_yookassa_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[YooKassaPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.transaction))
|
||||
.where(YooKassaPayment.user_id == user_id)
|
||||
.order_by(YooKassaPayment.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_pending_yookassa_payments(
|
||||
db: AsyncSession,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 100
|
||||
) -> List[YooKassaPayment]:
|
||||
|
||||
query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user))
|
||||
|
||||
conditions = [YooKassaPayment.status.in_(["pending", "waiting_for_capture"])]
|
||||
if user_id:
|
||||
conditions.append(YooKassaPayment.user_id == user_id)
|
||||
|
||||
result = await db.execute(
|
||||
query.where(and_(*conditions))
|
||||
.order_by(YooKassaPayment.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_succeeded_yookassa_payments_without_transaction(
|
||||
db: AsyncSession,
|
||||
limit: int = 50
|
||||
) -> List[YooKassaPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(
|
||||
and_(
|
||||
YooKassaPayment.status == "succeeded",
|
||||
YooKassaPayment.is_paid == True,
|
||||
YooKassaPayment.transaction_id == None
|
||||
)
|
||||
)
|
||||
.order_by(YooKassaPayment.captured_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_yookassa_payment(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_id: str
|
||||
) -> bool:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
)
|
||||
payment = result.scalar_one_or_none()
|
||||
|
||||
if payment:
|
||||
await db.delete(payment)
|
||||
await db.commit()
|
||||
logger.info(f"Удален платеж YooKassa: {yookassa_payment_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def get_yookassa_payments_stats(
|
||||
db: AsyncSession,
|
||||
user_id: Optional[int] = None
|
||||
) -> dict:
|
||||
|
||||
from sqlalchemy import func, case
|
||||
|
||||
query = select(
|
||||
func.count(YooKassaPayment.id).label('total_payments'),
|
||||
func.sum(YooKassaPayment.amount_kopeks).label('total_amount_kopeks'),
|
||||
func.sum(
|
||||
case(
|
||||
(YooKassaPayment.status == 'succeeded', YooKassaPayment.amount_kopeks),
|
||||
else_=0
|
||||
)
|
||||
).label('succeeded_amount_kopeks'),
|
||||
func.count(
|
||||
case(
|
||||
(YooKassaPayment.status == 'succeeded', 1),
|
||||
else_=None
|
||||
)
|
||||
).label('succeeded_count'),
|
||||
func.count(
|
||||
case(
|
||||
(YooKassaPayment.status == 'pending', 1),
|
||||
else_=None
|
||||
)
|
||||
).label('pending_count'),
|
||||
func.count(
|
||||
case(
|
||||
(YooKassaPayment.status.in_(['canceled', 'failed']), 1),
|
||||
else_=None
|
||||
)
|
||||
).label('failed_count')
|
||||
).select_from(YooKassaPayment)
|
||||
|
||||
if user_id:
|
||||
query = query.where(YooKassaPayment.user_id == user_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
stats = result.first()
|
||||
|
||||
return {
|
||||
'total_payments': stats.total_payments or 0,
|
||||
'total_amount_kopeks': stats.total_amount_kopeks or 0,
|
||||
'total_amount_rubles': (stats.total_amount_kopeks or 0) / 100,
|
||||
'succeeded_amount_kopeks': stats.succeeded_amount_kopeks or 0,
|
||||
'succeeded_amount_rubles': (stats.succeeded_amount_kopeks or 0) / 100,
|
||||
'succeeded_count': stats.succeeded_count or 0,
|
||||
'pending_count': stats.pending_count or 0,
|
||||
'failed_count': stats.failed_count or 0,
|
||||
'success_rate': (stats.succeeded_count / stats.total_payments * 100) if stats.total_payments > 0 else 0
|
||||
}
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import (
|
||||
ForeignKey, Float, JSON, BigInteger
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
@@ -44,8 +44,57 @@ class PromoCodeType(Enum):
|
||||
class PaymentMethod(Enum):
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
MANUAL = "manual"
|
||||
|
||||
class YooKassaPayment(Base):
|
||||
__tablename__ = "yookassa_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
yookassa_payment_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(3), default="RUB", nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(String(50), nullable=False)
|
||||
is_paid = Column(Boolean, default=False)
|
||||
is_captured = Column(Boolean, default=False)
|
||||
confirmation_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
payment_method_type = Column(String(50), nullable=True)
|
||||
refundable = Column(Boolean, default=False)
|
||||
test_mode = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
yookassa_created_at = Column(DateTime, nullable=True)
|
||||
captured_at = Column(DateTime, nullable=True)
|
||||
user = relationship("User", backref="yookassa_payments")
|
||||
transaction = relationship("Transaction", backref="yookassa_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == "pending"
|
||||
|
||||
@property
|
||||
def is_succeeded(self) -> bool:
|
||||
return self.status == "succeeded" and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ["canceled", "failed"]
|
||||
|
||||
@property
|
||||
def can_be_captured(self) -> bool:
|
||||
return self.status == "waiting_for_capture"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
@@ -55,31 +104,28 @@ 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)
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -104,7 +150,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)
|
||||
@@ -288,6 +334,33 @@ class Transaction(Base):
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
class SubscriptionConversion(Base):
|
||||
__tablename__ = "subscription_conversions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
converted_at = Column(DateTime, default=func.now())
|
||||
|
||||
trial_duration_days = Column(Integer, nullable=True)
|
||||
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
|
||||
first_payment_amount_kopeks = Column(Integer, nullable=True)
|
||||
|
||||
first_paid_period_days = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", backref="subscription_conversions")
|
||||
|
||||
@property
|
||||
def first_payment_amount_rubles(self) -> float:
|
||||
return (self.first_payment_amount_kopeks or 0) / 100
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SubscriptionConversion(user_id={self.user_id}, converted_at={self.converted_at})>"
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
__tablename__ = "promocodes"
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
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_table_exists(table_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"""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='{table_name}'
|
||||
"""))
|
||||
return result.fetchone() is not None
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = :table_name
|
||||
"""), {"table_name": table_name})
|
||||
return result.fetchone() is not None
|
||||
|
||||
elif db_type == 'mysql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = :table_name
|
||||
"""), {"table_name": table_name})
|
||||
return result.fetchone() is not None
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки существования таблицы {table_name}: {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 create_yookassa_payments_table():
|
||||
|
||||
table_exists = await check_table_exists('yookassa_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица yookassa_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE yookassa_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
|
||||
description TEXT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
is_paid BOOLEAN DEFAULT 0,
|
||||
is_captured BOOLEAN DEFAULT 0,
|
||||
confirmation_url TEXT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
payment_method_type VARCHAR(50) NULL,
|
||||
refundable BOOLEAN DEFAULT 0,
|
||||
test_mode BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
yookassa_created_at DATETIME NULL,
|
||||
captured_at DATETIME NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
|
||||
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
|
||||
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE yookassa_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
|
||||
description TEXT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
is_paid BOOLEAN DEFAULT FALSE,
|
||||
is_captured BOOLEAN DEFAULT FALSE,
|
||||
confirmation_url TEXT NULL,
|
||||
metadata_json JSONB NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
payment_method_type VARCHAR(50) NULL,
|
||||
refundable BOOLEAN DEFAULT FALSE,
|
||||
test_mode BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
yookassa_created_at TIMESTAMP NULL,
|
||||
captured_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
|
||||
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
|
||||
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE yookassa_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_kopeks INT NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
|
||||
description TEXT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
is_paid BOOLEAN DEFAULT FALSE,
|
||||
is_captured BOOLEAN DEFAULT FALSE,
|
||||
confirmation_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
transaction_id INT NULL,
|
||||
payment_method_type VARCHAR(50) NULL,
|
||||
refundable BOOLEAN DEFAULT FALSE,
|
||||
test_mode BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
yookassa_created_at DATETIME NULL,
|
||||
captured_at DATETIME NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
|
||||
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
|
||||
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица yookassa_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы yookassa_payments: {e}")
|
||||
return False
|
||||
|
||||
async def add_remnawave_v2_columns():
|
||||
|
||||
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 add_referral_system_columns():
|
||||
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
column_exists = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
if not column_exists:
|
||||
logger.info("Добавление колонки has_made_first_topup в таблицу users")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'BOOLEAN DEFAULT 0'
|
||||
else:
|
||||
column_def = 'BOOLEAN DEFAULT FALSE'
|
||||
|
||||
await conn.execute(text(f"ALTER TABLE users ADD COLUMN has_made_first_topup {column_def}"))
|
||||
logger.info("Колонка has_made_first_topup успешно добавлена")
|
||||
|
||||
logger.info("Обновление существующих пользователей...")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
update_sql = """
|
||||
UPDATE users
|
||||
SET has_made_first_topup = 1
|
||||
WHERE balance_kopeks > 0 OR has_had_paid_subscription = 1
|
||||
"""
|
||||
else:
|
||||
update_sql = """
|
||||
UPDATE users
|
||||
SET has_made_first_topup = TRUE
|
||||
WHERE balance_kopeks > 0 OR has_had_paid_subscription = TRUE
|
||||
"""
|
||||
|
||||
result = await conn.execute(text(update_sql))
|
||||
updated_count = result.rowcount
|
||||
|
||||
logger.info(f"Обновлено {updated_count} пользователей с has_made_first_topup = TRUE")
|
||||
logger.info("✅ Миграция реферальной системы завершена")
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.info("Колонка has_made_first_topup уже существует")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка миграции реферальной системы: {e}")
|
||||
return False
|
||||
|
||||
async def create_subscription_conversions_table():
|
||||
|
||||
table_exists = await check_table_exists('subscription_conversions')
|
||||
if table_exists:
|
||||
logger.info("Таблица subscription_conversions уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_conversions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
converted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
trial_duration_days INTEGER NULL,
|
||||
payment_method VARCHAR(50) NULL,
|
||||
first_payment_amount_kopeks INTEGER NULL,
|
||||
first_paid_period_days INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
|
||||
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_conversions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
converted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
trial_duration_days INTEGER NULL,
|
||||
payment_method VARCHAR(50) NULL,
|
||||
first_payment_amount_kopeks INTEGER NULL,
|
||||
first_paid_period_days INTEGER NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
|
||||
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_conversions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
converted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
trial_duration_days INT NULL,
|
||||
payment_method VARCHAR(50) NULL,
|
||||
first_payment_amount_kopeks INT NULL,
|
||||
first_paid_period_days INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
|
||||
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица subscription_conversions успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы subscription_conversions: {e}")
|
||||
return False
|
||||
|
||||
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()
|
||||
|
||||
referral_migration_success = await add_referral_system_columns()
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ YOOKASSA ===")
|
||||
yookassa_created = await create_yookassa_payments_table()
|
||||
if yookassa_created:
|
||||
logger.info("✅ Таблица YooKassa payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей YooKassa payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===")
|
||||
conversions_created = await create_subscription_conversions_table()
|
||||
if conversions_created:
|
||||
logger.info("✅ Таблица subscription_conversions готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
|
||||
|
||||
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("База данных уже в корректном состоянии")
|
||||
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("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
|
||||
logger.info("✅ Реферальная система обновлена")
|
||||
logger.info("✅ RemnaWave v2.1.5 колонки добавлены")
|
||||
logger.info("✅ YooKassa таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Дубликаты подписок исправлены")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===")
|
||||
return False
|
||||
|
||||
async def check_migration_status():
|
||||
logger.info("=== ПРОВЕРКА СТАТУСА МИГРАЦИЙ ===")
|
||||
|
||||
try:
|
||||
status = {
|
||||
"has_made_first_topup_column": False,
|
||||
"yookassa_table": False,
|
||||
"remnawave_v2_columns": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
}
|
||||
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
status["yookassa_table"] = await check_table_exists('yookassa_payments')
|
||||
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
|
||||
remnawave_columns = ['lifetime_used_traffic_bytes', 'last_remnawave_sync', 'trojan_password', 'vless_uuid', 'ss_password']
|
||||
remnawave_status = []
|
||||
for col in remnawave_columns:
|
||||
exists = await check_column_exists('users', col)
|
||||
remnawave_status.append(exists)
|
||||
status["remnawave_v2_columns"] = all(remnawave_status)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM subscriptions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(*) > 1
|
||||
) as dups
|
||||
"""))
|
||||
duplicates_count = duplicates_check.fetchone()[0]
|
||||
status["subscription_duplicates"] = (duplicates_count == 0)
|
||||
|
||||
check_names = {
|
||||
"has_made_first_topup_column": "Колонка реферальной системы",
|
||||
"yookassa_table": "Таблица YooKassa payments",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"remnawave_v2_columns": "Колонки RemnaWave v2.1.5",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
check_name = check_names.get(check_key, check_key)
|
||||
icon = "✅" if check_status else "❌"
|
||||
logger.info(f"{icon} {check_name}: {'OK' if check_status else 'ТРЕБУЕТ ВНИМАНИЯ'}")
|
||||
|
||||
all_good = all(status.values())
|
||||
if all_good:
|
||||
logger.info("🎉 Все миграции выполнены успешно!")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
conversions_count = await conn.execute(text("SELECT COUNT(*) FROM subscription_conversions"))
|
||||
users_count = await conn.execute(text("SELECT COUNT(*) FROM users"))
|
||||
|
||||
conv_count = conversions_count.fetchone()[0]
|
||||
usr_count = users_count.fetchone()[0]
|
||||
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий записано")
|
||||
except Exception as stats_error:
|
||||
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
|
||||
|
||||
else:
|
||||
logger.warning("⚠️ Некоторые миграции требуют внимания")
|
||||
missing_migrations = [check_names[k] for k, v in status.items() if not v]
|
||||
logger.warning(f"Требуют выполнения: {', '.join(missing_migrations)}")
|
||||
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса миграций: {e}")
|
||||
return None
|
||||
@@ -8,6 +8,7 @@ import aiohttp
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,7 +34,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
|
||||
@@ -46,6 +47,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
|
||||
@@ -82,10 +93,12 @@ class RemnaWaveAPIError(Exception):
|
||||
|
||||
class RemnaWaveAPI:
|
||||
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.secret_key = secret_key
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.authenticated = False
|
||||
|
||||
def _detect_connection_type(self) -> str:
|
||||
parsed = urlparse(self.base_url)
|
||||
@@ -114,18 +127,29 @@ class RemnaWaveAPI:
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
'X-Real-IP': '127.0.0.1'
|
||||
}
|
||||
|
||||
cookies = None
|
||||
if self.secret_key:
|
||||
if ':' in self.secret_key:
|
||||
key_name, key_value = self.secret_key.split(':', 1)
|
||||
cookies = {key_name: key_value}
|
||||
logger.debug(f"🍪 Используем куки: {key_name}=***")
|
||||
else:
|
||||
cookies = {self.secret_key: self.secret_key}
|
||||
logger.debug(f"🍪 Используем куки: {self.secret_key}=***")
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
@@ -142,11 +166,18 @@ class RemnaWaveAPI:
|
||||
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
|
||||
self.session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
headers=headers,
|
||||
connector=connector
|
||||
)
|
||||
session_kwargs = {
|
||||
'timeout': aiohttp.ClientTimeout(total=30),
|
||||
'headers': headers,
|
||||
'connector': connector
|
||||
}
|
||||
|
||||
if cookies:
|
||||
session_kwargs['cookies'] = cookies
|
||||
|
||||
self.session = aiohttp.ClientSession(**session_kwargs)
|
||||
self.authenticated = True
|
||||
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
@@ -184,6 +215,8 @@ class RemnaWaveAPI:
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f"API Error {response.status}: {error_message}")
|
||||
logger.error(f"Response: {response_text[:500]}")
|
||||
raise RemnaWaveAPIError(
|
||||
error_message,
|
||||
response.status,
|
||||
@@ -498,8 +531,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(
|
||||
|
||||
@@ -13,6 +13,14 @@ class TelegramStarsService:
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
|
||||
@staticmethod
|
||||
def calculate_stars_from_rubles(rubles: float) -> int:
|
||||
return settings.rubles_to_stars(rubles)
|
||||
|
||||
@staticmethod
|
||||
def calculate_rubles_from_stars(stars: int) -> float:
|
||||
return settings.stars_to_rubles(stars)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
chat_id: int,
|
||||
@@ -23,7 +31,8 @@ class TelegramStarsService:
|
||||
start_parameter: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
stars_amount = max(1, amount_kopeks // 100)
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
@@ -35,7 +44,10 @@ class TelegramStarsService:
|
||||
start_parameter=start_parameter
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд для {chat_id}")
|
||||
logger.info(
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽) "
|
||||
f"для {chat_id}, курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
)
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
@@ -52,7 +64,8 @@ class TelegramStarsService:
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
stars_amount = max(1, amount_kopeks // 100)
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
chat_id=chat_id,
|
||||
@@ -65,10 +78,14 @@ class TelegramStarsService:
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
logger.info(f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд")
|
||||
logger.info(
|
||||
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
|
||||
f"(~{amount_rubles:.2f}₽), курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
)
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"stars_amount": stars_amount,
|
||||
"rubles_amount": amount_rubles,
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
@@ -88,13 +105,8 @@ class TelegramStarsService:
|
||||
ok=ok,
|
||||
error_message=error_message
|
||||
)
|
||||
logger.info(f"Ответ на pre_checkout_query: ok={ok}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка ответа на pre_checkout_query: {e}")
|
||||
return False
|
||||
|
||||
def calculate_stars_amount(self, rubles: float) -> int:
|
||||
return max(1, int(rubles))
|
||||
|
||||
def calculate_rubles_from_stars(self, stars: int) -> float:
|
||||
return float(stars)
|
||||
@@ -28,7 +28,6 @@ class TributeService:
|
||||
return None
|
||||
|
||||
try:
|
||||
|
||||
payment_url = f"{self.donate_link}&user_id={user_id}"
|
||||
|
||||
logger.info(f"Создана ссылка Tribute для пользователя {user_id}")
|
||||
@@ -41,7 +40,7 @@ class TributeService:
|
||||
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
|
||||
|
||||
if not self.webhook_secret:
|
||||
logger.warning("Webhook secret не настроен")
|
||||
logger.warning("Webhook secret не настроен, пропускаем проверку")
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -51,31 +50,54 @@ class TributeService:
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись Tribute webhook проверена успешно")
|
||||
else:
|
||||
logger.error("❌ Неверная подпись Tribute webhook")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи webhook: {e}")
|
||||
return False
|
||||
|
||||
async def process_webhook(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
async def process_webhook(self, payload_or_data) -> Optional[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
logger.info(f"🔄 Начинаем обработку Tribute webhook")
|
||||
|
||||
payment_id = payload.get("id") or payload.get("payment_id")
|
||||
status = payload.get("status")
|
||||
amount_rubles = payload.get("amount", 0)
|
||||
telegram_user_id = payload.get("telegram_user_id") or payload.get("user_id")
|
||||
if isinstance(payload_or_data, str):
|
||||
try:
|
||||
webhook_data = json.loads(payload_or_data)
|
||||
logger.info(f"📊 Распарсенные данные: {webhook_data}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON: {e}")
|
||||
return None
|
||||
else:
|
||||
webhook_data = payload_or_data
|
||||
|
||||
if not payment_id and "payload" in payload:
|
||||
data = payload["payload"]
|
||||
payment_id = None
|
||||
status = None
|
||||
amount_kopeks = 0
|
||||
telegram_user_id = None
|
||||
|
||||
payment_id = webhook_data.get("id") or webhook_data.get("payment_id")
|
||||
status = webhook_data.get("status")
|
||||
amount_kopeks = webhook_data.get("amount", 0)
|
||||
telegram_user_id = webhook_data.get("telegram_user_id") or webhook_data.get("user_id")
|
||||
|
||||
if not payment_id and "payload" in webhook_data:
|
||||
data = webhook_data["payload"]
|
||||
payment_id = data.get("id") or data.get("payment_id")
|
||||
status = data.get("status")
|
||||
amount_rubles = data.get("amount", 0)
|
||||
amount_kopeks = data.get("amount", 0)
|
||||
telegram_user_id = data.get("telegram_user_id") or data.get("user_id")
|
||||
|
||||
if not payment_id and "name" in payload:
|
||||
event_name = payload.get("name")
|
||||
data = payload.get("payload", {})
|
||||
if not payment_id and "name" in webhook_data:
|
||||
event_name = webhook_data.get("name")
|
||||
data = webhook_data.get("payload", {})
|
||||
payment_id = str(data.get("donation_request_id"))
|
||||
amount_kopeks = data.get("amount", 0)
|
||||
telegram_user_id = data.get("telegram_user_id")
|
||||
@@ -87,22 +109,54 @@ class TributeService:
|
||||
else:
|
||||
status = "unknown"
|
||||
|
||||
logger.info(f"Обработка Tribute webhook: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}")
|
||||
logger.info(f"📝 Извлеченные данные: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}")
|
||||
|
||||
if not telegram_user_id:
|
||||
logger.error("Не найден telegram_user_id в webhook данных")
|
||||
logger.error("❌ Не найден telegram_user_id в webhook данных")
|
||||
logger.error(f"🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
|
||||
return None
|
||||
|
||||
return {
|
||||
try:
|
||||
telegram_user_id = int(telegram_user_id)
|
||||
except (ValueError, TypeError):
|
||||
logger.error(f"❌ Некорректный telegram_user_id: {telegram_user_id}")
|
||||
return None
|
||||
|
||||
result = {
|
||||
"event_type": "payment",
|
||||
"payment_id": payment_id or f"tribute_{telegram_user_id}_{amount_kopeks}",
|
||||
"user_id": int(telegram_user_id),
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"user_id": telegram_user_id,
|
||||
"amount_kopeks": int(amount_kopeks) if amount_kopeks else 0,
|
||||
"status": status or "paid",
|
||||
"external_id": f"donation_{payment_id}"
|
||||
"external_id": f"donation_{payment_id or 'unknown'}",
|
||||
"payment_system": "tribute"
|
||||
}
|
||||
|
||||
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Tribute webhook: {e}")
|
||||
logger.error(f"Webhook payload: {json.dumps(payload, ensure_ascii=False)}")
|
||||
return None
|
||||
logger.error(f"❌ Ошибка обработки Tribute webhook: {e}", exc_info=True)
|
||||
logger.error(f"🔍 Webhook data для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
|
||||
return None
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
logger.info(f"Запрос статуса платежа {payment_id}")
|
||||
return {"status": "unknown", "payment_id": payment_id}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса платежа: {e}")
|
||||
return None
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
payment_id: str,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
reason: str = "Возврат по запросу"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
logger.info(f"Создание возврата для платежа {payment_id}")
|
||||
return {"refund_id": f"refund_{payment_id}", "status": "pending"}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания возврата: {e}")
|
||||
return None
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
@@ -26,9 +27,11 @@ class WebhookServer:
|
||||
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: /health")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
|
||||
return self.app
|
||||
|
||||
@@ -49,11 +52,11 @@ class WebhookServer:
|
||||
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"✅ Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 Tribute webhook URL: http://your-server:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
logger.info(f"✅ Tribute webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска webhook сервера: {e}")
|
||||
logger.error(f"❌ Ошибка запуска Tribute webhook сервера: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
@@ -61,31 +64,82 @@ class WebhookServer:
|
||||
try:
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
logger.info("Webhook сайт остановлен")
|
||||
logger.info("Tribute webhook сайт остановлен")
|
||||
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("Webhook runner очищен")
|
||||
logger.info("Tribute webhook runner очищен")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки webhook сервера: {e}")
|
||||
logger.error(f"Ошибка остановки Tribute webhook сервера: {e}")
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-Tribute-Signature',
|
||||
}
|
||||
)
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("⚠️ Получен пустой webhook от Tribute")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "empty_body"},
|
||||
status=400
|
||||
)
|
||||
|
||||
payload = raw_body.decode('utf-8')
|
||||
logger.info(f"📄 Payload: {payload}")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
logger.info(f"📊 Распарсенные данные: {webhook_data}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON: {e}")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_json"},
|
||||
status=400
|
||||
)
|
||||
|
||||
signature = request.headers.get('X-Tribute-Signature')
|
||||
logger.info(f"🔐 Signature: {signature}")
|
||||
|
||||
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
tribute_api = TributeAPI()
|
||||
if not tribute_api.verify_webhook_signature(payload, signature):
|
||||
logger.error("❌ Неверная подпись Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
result = await self.tribute_service.process_webhook(payload, signature)
|
||||
|
||||
return web.json_response(result, status=200)
|
||||
if result:
|
||||
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
|
||||
return web.json_response({"status": "ok", "result": result}, status=200)
|
||||
else:
|
||||
logger.error("❌ Ошибка обработки Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "processing_failed"},
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Tribute webhook: {e}")
|
||||
logger.error(f"❌ Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "internal_error"},
|
||||
{"status": "error", "reason": "internal_error", "message": str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
@@ -94,5 +148,7 @@ class WebhookServer:
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "tribute-webhooks",
|
||||
"tribute_enabled": settings.TRIBUTE_ENABLED
|
||||
})
|
||||
"tribute_enabled": settings.TRIBUTE_ENABLED,
|
||||
"port": settings.TRIBUTE_WEBHOOK_PORT,
|
||||
"path": settings.TRIBUTE_WEBHOOK_PATH
|
||||
})
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
from typing import Optional, Dict, Any
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YooKassaWebhookHandler:
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
||||
try:
|
||||
signature_parts = signature.strip().split(' ')
|
||||
|
||||
if len(signature_parts) < 4:
|
||||
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
||||
return False
|
||||
|
||||
version = signature_parts[0]
|
||||
payment_id = signature_parts[1]
|
||||
timestamp = signature_parts[2]
|
||||
received_signature = signature_parts[3]
|
||||
|
||||
if version != "v1":
|
||||
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
||||
return False
|
||||
|
||||
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
||||
|
||||
|
||||
expected_signature_1 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
||||
|
||||
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
||||
expected_signature_2 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_2.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
||||
|
||||
signed_payload_3 = f"{timestamp}.{body}"
|
||||
expected_signature_3 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_3.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
||||
|
||||
logger.debug(f"Получена подпись: {received_signature}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
||||
|
||||
is_valid = (
|
||||
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
||||
return False
|
||||
|
||||
def __init__(self, payment_service: PaymentService):
|
||||
self.payment_service = payment_service
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
|
||||
body = await request.text()
|
||||
|
||||
if not body:
|
||||
logger.warning("⚠️ Получен пустой webhook от YooKassa")
|
||||
return web.Response(status=400, text="Empty body")
|
||||
|
||||
logger.info(f"📄 Body: {body}")
|
||||
|
||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||
|
||||
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
|
||||
logger.info(f"🔐 Получена подпись: {signature}")
|
||||
|
||||
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
|
||||
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
|
||||
else:
|
||||
logger.info("✅ Подпись webhook проверена успешно")
|
||||
|
||||
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
|
||||
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
||||
|
||||
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
|
||||
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
||||
|
||||
else:
|
||||
logger.info("ℹ️ Проверка подписи отключена")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
||||
|
||||
event_type = webhook_data.get("event")
|
||||
if not event_type:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
return web.Response(status=400, text="No event type")
|
||||
|
||||
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
|
||||
return web.Response(status=500, text="Processing error")
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
def setup_routes(self, app: web.Application) -> None:
|
||||
|
||||
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
|
||||
app.router.add_post(webhook_path, self.handle_webhook)
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
|
||||
|
||||
async def _get_handler(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"message": "YooKassa webhook endpoint is working",
|
||||
"method": "GET",
|
||||
"path": request.path,
|
||||
"note": "Use POST method for actual webhooks"
|
||||
})
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-YooKassa-Signature',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
|
||||
app = web.Application()
|
||||
|
||||
webhook_handler = YooKassaWebhookHandler(payment_service)
|
||||
webhook_handler.setup_routes(app)
|
||||
|
||||
async def health_check(request):
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "yookassa_webhook",
|
||||
"port": settings.YOOKASSA_WEBHOOK_PORT,
|
||||
"path": settings.YOOKASSA_WEBHOOK_PATH,
|
||||
"enabled": settings.is_yookassa_enabled()
|
||||
})
|
||||
|
||||
app.router.add_get("/health", health_check)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
try:
|
||||
app = create_yookassa_webhook_app(payment_service)
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host='0.0.0.0',
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
await site.start()
|
||||
|
||||
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("🛑 YooKassa webhook сервер получил сигнал остановки")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("✅ YooKassa webhook сервер остановлен")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
|
||||
raise
|
||||
@@ -0,0 +1,235 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.keyboards.admin import get_maintenance_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaintenanceStates(StatesGroup):
|
||||
waiting_for_reason = State()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_maintenance_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
status_info = maintenance_service.get_status_info()
|
||||
|
||||
status_emoji = "🔧" if status_info["is_active"] else "✅"
|
||||
status_text = "Включен" if status_info["is_active"] else "Выключен"
|
||||
|
||||
api_emoji = "✅" if status_info["api_status"] else "❌"
|
||||
api_text = "Доступно" if status_info["api_status"] else "Недоступно"
|
||||
|
||||
monitoring_emoji = "🔄" if status_info["monitoring_active"] else "⏹️"
|
||||
monitoring_text = "Запущен" if status_info["monitoring_active"] else "Остановлен"
|
||||
|
||||
enabled_info = ""
|
||||
if status_info["is_active"] and status_info["enabled_at"]:
|
||||
enabled_time = status_info["enabled_at"].strftime("%d.%m.%Y %H:%M:%S")
|
||||
enabled_info = f"\n📅 <b>Включен:</b> {enabled_time}"
|
||||
if status_info["reason"]:
|
||||
enabled_info += f"\n📝 <b>Причина:</b> {status_info['reason']}"
|
||||
|
||||
last_check_info = ""
|
||||
if status_info["last_check"]:
|
||||
last_check_time = status_info["last_check"].strftime("%H:%M:%S")
|
||||
last_check_info = f"\n🕐 <b>Последняя проверка:</b> {last_check_time}"
|
||||
|
||||
failures_info = ""
|
||||
if status_info["consecutive_failures"] > 0:
|
||||
failures_info = f"\n⚠️ <b>Неудачных проверок подряд:</b> {status_info['consecutive_failures']}"
|
||||
|
||||
message_text = f"""
|
||||
🔧 <b>Режим технических работ</b>
|
||||
|
||||
{status_emoji} <b>Статус:</b> {status_text}
|
||||
{api_emoji} <b>API RemnaWave:</b> {api_text}
|
||||
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
|
||||
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
|
||||
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
|
||||
{enabled_info}
|
||||
{last_check_info}
|
||||
{failures_info}
|
||||
|
||||
ℹ️ <i>В режиме техработ обычные пользователи не могут использовать бота. Администраторы имеют полный доступ.</i>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_maintenance_keyboard(db_user.language, status_info["is_active"], status_info["monitoring_active"])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_maintenance_mode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
is_active = maintenance_service.is_maintenance_active()
|
||||
|
||||
if is_active:
|
||||
success = await maintenance_service.disable_maintenance()
|
||||
if success:
|
||||
await callback.answer("Режим техработ выключен", show_alert=True)
|
||||
else:
|
||||
await callback.answer("Ошибка выключения режима техработ", show_alert=True)
|
||||
else:
|
||||
await state.set_state(MaintenanceStates.waiting_for_reason)
|
||||
await callback.message.edit_text(
|
||||
"🔧 <b>Включение режима техработ</b>\n\nВведите причину включения техработ или отправьте /skip для пропуска:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_maintenance_reason(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
|
||||
if current_state != MaintenanceStates.waiting_for_reason:
|
||||
return
|
||||
|
||||
reason = None
|
||||
if message.text and message.text != "/skip":
|
||||
reason = message.text[:200]
|
||||
|
||||
success = await maintenance_service.enable_maintenance(reason=reason, auto=False)
|
||||
|
||||
if success:
|
||||
response_text = "Режим техработ включен"
|
||||
if reason:
|
||||
response_text += f"\nПричина: {reason}"
|
||||
else:
|
||||
response_text = "Ошибка включения режима техработ"
|
||||
|
||||
await message.answer(response_text)
|
||||
await state.clear()
|
||||
|
||||
status_info = maintenance_service.get_status_info()
|
||||
await message.answer(
|
||||
"Вернуться к панели управления техработами:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_monitoring(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
status_info = maintenance_service.get_status_info()
|
||||
|
||||
if status_info["monitoring_active"]:
|
||||
success = await maintenance_service.stop_monitoring()
|
||||
message = "Мониторинг остановлен" if success else "Ошибка остановки мониторинга"
|
||||
else:
|
||||
success = await maintenance_service.start_monitoring()
|
||||
message = "Мониторинг запущен" if success else "Ошибка запуска мониторинга"
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
await show_maintenance_panel(callback, db_user, db, None)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def force_api_check(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("Проверка API...", show_alert=False)
|
||||
|
||||
check_result = await maintenance_service.force_api_check()
|
||||
|
||||
if check_result["success"]:
|
||||
status_text = "доступно" if check_result["api_available"] else "недоступно"
|
||||
message = f"API {status_text}\nВремя ответа: {check_result['response_time']}с"
|
||||
else:
|
||||
message = f"Ошибка проверки: {check_result.get('error', 'Неизвестная ошибка')}"
|
||||
|
||||
await callback.message.answer(message)
|
||||
|
||||
await show_maintenance_panel(callback, db_user, db, None)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def back_to_admin_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.ADMIN_PANEL,
|
||||
reply_markup=get_admin_main_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_maintenance_panel,
|
||||
F.data == "maintenance_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_maintenance_mode,
|
||||
F.data == "maintenance_toggle"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_monitoring,
|
||||
F.data == "maintenance_monitoring"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
force_api_check,
|
||||
F.data == "maintenance_check_api"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
back_to_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_maintenance_reason,
|
||||
MaintenanceStates.waiting_for_reason
|
||||
)
|
||||
@@ -45,7 +45,8 @@ async def show_messages_menu(
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language)
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -60,7 +61,8 @@ async def show_broadcast_targets(
|
||||
await callback.message.edit_text(
|
||||
"🎯 <b>Выбор целевой аудитории</b>\n\n"
|
||||
"Выберите категорию пользователей для рассылки:",
|
||||
reply_markup=get_broadcast_target_keyboard(db_user.language)
|
||||
reply_markup=get_broadcast_target_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -118,7 +120,8 @@ async def show_messages_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()
|
||||
|
||||
@@ -135,7 +138,7 @@ async def show_custom_broadcast(
|
||||
stats = await get_users_statistics(db)
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Рассылка по критериям</b>
|
||||
📝 <b>Рассылка по критериям</b>
|
||||
|
||||
📊 <b>Доступные фильтры:</b>
|
||||
|
||||
@@ -158,7 +161,8 @@ async def show_custom_broadcast(
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_custom_criteria_keyboard(db_user.language)
|
||||
reply_markup=get_custom_criteria_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -196,7 +200,8 @@ async def select_custom_criteria(
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
])
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
@@ -233,7 +238,8 @@ async def select_broadcast_target(
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
])
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
@@ -284,7 +290,8 @@ async def process_broadcast_message(
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
@@ -304,7 +311,8 @@ async def confirm_broadcast(
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
"⏳ Это может занять несколько минут.",
|
||||
reply_markup=None
|
||||
reply_markup=None,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if target.startswith('custom_'):
|
||||
@@ -367,7 +375,8 @@ async def confirm_broadcast(
|
||||
result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
|
||||
])
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
@@ -548,4 +557,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
|
||||
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
|
||||
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, PromoCodeType
|
||||
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User
|
||||
from app.keyboards.admin import (
|
||||
get_admin_promocodes_keyboard, get_promocode_type_keyboard,
|
||||
get_admin_pagination_keyboard, get_confirmation_keyboard
|
||||
@@ -77,6 +77,7 @@ async def show_promocodes_list(
|
||||
return
|
||||
|
||||
text = f"🎫 <b>Список промокодов</b> (стр. {page}/{total_pages})\n\n"
|
||||
keyboard = []
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
@@ -93,9 +94,14 @@ async def show_promocodes_list(
|
||||
if promo.valid_until:
|
||||
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
text += f"🔧 Управление: /promo_{promo.id}\n\n"
|
||||
|
||||
keyboard = []
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"🎫 {promo.code}",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
])
|
||||
|
||||
text += "\n"
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
@@ -182,128 +188,75 @@ async def show_promocode_management(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_promocode_status(
|
||||
async def show_promocode_edit_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not promo.is_active
|
||||
await update_promocode(db, promo, is_active=new_status)
|
||||
|
||||
status_text = "активирован" if new_status else "деактивирован"
|
||||
await callback.answer(f"✅ Промокод {status_text}", show_alert=True)
|
||||
|
||||
await show_promocode_management(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_delete_promocode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text = f"""
|
||||
⚠️ <b>Подтверждение удаления</b>
|
||||
✏️ <b>Редактирование промокода</b> <code>{promo.code}</code>
|
||||
|
||||
Вы действительно хотите удалить промокод <code>{promo.code}</code>?
|
||||
|
||||
<b>Внимание:</b> Это действие нельзя отменить!
|
||||
💰 <b>Текущие параметры:</b>
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"• Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type in [PromoCodeType.SUBSCRIPTION_DAYS.value, PromoCodeType.TRIAL_SUBSCRIPTION.value]:
|
||||
text += f"• Дней: {promo.subscription_days}\n"
|
||||
|
||||
text += f"• Использований: {promo.current_uses}/{promo.max_uses}\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"• До: {format_datetime(promo.valid_until)}\n"
|
||||
else:
|
||||
text += f"• Срок: бессрочно\n"
|
||||
|
||||
text += f"\nВыберите параметр для изменения:"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=f"promo_delete_confirm_{promo.id}"
|
||||
),
|
||||
text="📅 Дата окончания",
|
||||
callback_data=f"promo_edit_date_{promo.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
text="📊 Количество использований",
|
||||
callback_data=f"promo_edit_uses_{promo.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
]
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_promocode_confirmed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
keyboard.insert(1, [
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Сумма бонуса",
|
||||
callback_data=f"promo_edit_amount_{promo.id}"
|
||||
)
|
||||
])
|
||||
elif promo.type in [PromoCodeType.SUBSCRIPTION_DAYS.value, PromoCodeType.TRIAL_SUBSCRIPTION.value]:
|
||||
keyboard.insert(1, [
|
||||
types.InlineKeyboardButton(
|
||||
text="📅 Количество дней",
|
||||
callback_data=f"promo_edit_days_{promo.id}"
|
||||
)
|
||||
])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
code = promo.code
|
||||
success = await delete_promocode(db, promo)
|
||||
|
||||
if success:
|
||||
await callback.answer(f"✅ Промокод {code} удален", show_alert=True)
|
||||
await show_promocodes_list(callback, db_user, db)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка удаления промокода", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocode_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
stats = await get_promocode_statistics(db, promo_id)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика промокода</b> <code>{promo.code}</code>
|
||||
|
||||
📈 <b>Общая статистика:</b>
|
||||
- Всего использований: {stats['total_uses']}
|
||||
- Использований сегодня: {stats['today_uses']}
|
||||
- Осталось использований: {promo.max_uses - promo.current_uses}
|
||||
|
||||
📅 <b>Последние использования:</b>
|
||||
"""
|
||||
|
||||
for use in stats['recent_uses'][:5]:
|
||||
use_date = format_datetime(use.used_at)
|
||||
text += f"- {use_date} (ID: {use.user_id})\n"
|
||||
|
||||
if not stats['recent_uses']:
|
||||
text += "- Пока не было использований\n"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
@@ -312,7 +265,160 @@ async def show_promocode_stats(
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promocode_date(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
editing_promo_id=promo_id,
|
||||
edit_action="date"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
📅 <b>Изменение даты окончания промокода</b>
|
||||
|
||||
Введите количество дней до окончания (от текущего момента):
|
||||
• Введите <b>0</b> для бессрочного промокода
|
||||
• Введите положительное число для установки срока
|
||||
|
||||
<i>Например: 30 (промокод будет действовать 30 дней)</i>
|
||||
|
||||
ID промокода: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.setting_promocode_expiry)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promocode_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
editing_promo_id=promo_id,
|
||||
edit_action="amount"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
💰 <b>Изменение суммы бонуса промокода</b>
|
||||
|
||||
Введите новую сумму в рублях:
|
||||
<i>Например: 500</i>
|
||||
|
||||
ID промокода: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promocode_days(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
# ИСПРАВЛЕНИЕ: берем последний элемент как ID
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
editing_promo_id=promo_id,
|
||||
edit_action="days"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
📅 <b>Изменение количества дней подписки</b>
|
||||
|
||||
Введите новое количество дней:
|
||||
<i>Например: 30</i>
|
||||
|
||||
ID промокода: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promocode_uses(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
editing_promo_id=promo_id,
|
||||
edit_action="uses"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Изменение максимального количества использований</b>
|
||||
|
||||
Введите новое количество использований:
|
||||
• Введите <b>0</b> для безлимитных использований
|
||||
• Введите положительное число для ограничения
|
||||
|
||||
<i>Например: 100</i>
|
||||
|
||||
ID промокода: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.setting_promocode_uses)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -375,7 +481,6 @@ async def process_promocode_code(
|
||||
await message.answer("❌ Код должен содержать только латинские буквы и цифры (3-20 символов)")
|
||||
return
|
||||
|
||||
from app.database.crud.promocode import get_promocode_by_code
|
||||
existing = await get_promocode_by_code(db, code)
|
||||
if existing:
|
||||
await message.answer("❌ Промокод с таким кодом уже существует")
|
||||
@@ -411,12 +516,18 @@ async def process_promocode_code(
|
||||
async def process_promocode_value(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
|
||||
if data.get('editing_promo_id'):
|
||||
await handle_edit_value(message, db_user, state, db)
|
||||
return
|
||||
|
||||
try:
|
||||
value = int(message.text.strip())
|
||||
|
||||
data = await state.get_data()
|
||||
promo_type = data.get('promocode_type')
|
||||
|
||||
if promo_type == "balance" and (value < 1 or value > 10000):
|
||||
@@ -437,13 +548,72 @@ async def process_promocode_value(
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
async def handle_edit_value(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
edit_action = data.get('edit_action')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
value = int(message.text.strip())
|
||||
|
||||
if edit_action == "amount":
|
||||
if value < 1 or value > 10000:
|
||||
await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей")
|
||||
return
|
||||
|
||||
await update_promocode(db, promo, balance_bonus_kopeks=value * 100)
|
||||
await message.answer(
|
||||
f"✅ Сумма бонуса изменена на {value}₽",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
elif edit_action == "days":
|
||||
if value < 1 or value > 3650:
|
||||
await message.answer("❌ Количество дней должно быть от 1 до 3650")
|
||||
return
|
||||
|
||||
await update_promocode(db, promo, subscription_days=value)
|
||||
await message.answer(
|
||||
f"✅ Количество дней изменено на {value}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: {edit_action} = {value}")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_uses(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
|
||||
if data.get('editing_promo_id'):
|
||||
await handle_edit_uses(message, db_user, state, db)
|
||||
return
|
||||
|
||||
try:
|
||||
max_uses = int(message.text.strip())
|
||||
|
||||
@@ -465,6 +635,54 @@ async def process_promocode_uses(
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
async def handle_edit_uses(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
max_uses = int(message.text.strip())
|
||||
|
||||
if max_uses < 0 or max_uses > 100000:
|
||||
await message.answer("❌ Количество использований должно быть от 0 до 100,000")
|
||||
return
|
||||
|
||||
if max_uses == 0:
|
||||
max_uses = 999999
|
||||
|
||||
if max_uses < promo.current_uses:
|
||||
await message.answer(
|
||||
f"❌ Новый лимит ({max_uses}) не может быть меньше текущих использований ({promo.current_uses})"
|
||||
)
|
||||
return
|
||||
|
||||
await update_promocode(db, promo, max_uses=max_uses)
|
||||
|
||||
uses_text = "безлимитное" if max_uses == 999999 else str(max_uses)
|
||||
await message.answer(
|
||||
f"✅ Максимальное количество использований изменено на {uses_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: max_uses = {max_uses}")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_expiry(
|
||||
@@ -473,6 +691,12 @@ async def process_promocode_expiry(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
|
||||
if data.get('editing_promo_id'):
|
||||
await handle_edit_expiry(message, db_user, state, db)
|
||||
return
|
||||
|
||||
try:
|
||||
expiry_days = int(message.text.strip())
|
||||
|
||||
@@ -480,7 +704,6 @@ async def process_promocode_expiry(
|
||||
await message.answer("❌ Срок действия должен быть от 0 до 3650 дней")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
code = data.get('promocode_code')
|
||||
promo_type = data.get('promocode_type')
|
||||
value = data.get('promocode_value', 0)
|
||||
@@ -544,6 +767,241 @@ async def process_promocode_expiry(
|
||||
await message.answer("❌ Введите корректное число дней")
|
||||
|
||||
|
||||
async def handle_edit_expiry(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
expiry_days = int(message.text.strip())
|
||||
|
||||
if expiry_days < 0 or expiry_days > 3650:
|
||||
await message.answer("❌ Срок действия должен быть от 0 до 3650 дней")
|
||||
return
|
||||
|
||||
valid_until = None
|
||||
if expiry_days > 0:
|
||||
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
await update_promocode(db, promo, valid_until=valid_until)
|
||||
|
||||
if valid_until:
|
||||
expiry_text = f"до {format_datetime(valid_until)}"
|
||||
else:
|
||||
expiry_text = "бессрочно"
|
||||
|
||||
await message.answer(
|
||||
f"✅ Срок действия промокода изменен: {expiry_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: expiry = {expiry_days} дней")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число дней")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_promocode_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not promo.is_active
|
||||
await update_promocode(db, promo, is_active=new_status)
|
||||
|
||||
status_text = "активирован" if new_status else "деактивирован"
|
||||
await callback.answer(f"✅ Промокод {status_text}", show_alert=True)
|
||||
|
||||
await show_promocode_management(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_delete_promocode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text = f"""
|
||||
⚠️ <b>Подтверждение удаления</b>
|
||||
|
||||
Вы действительно хотите удалить промокод <code>{promo.code}</code>?
|
||||
|
||||
📊 <b>Информация о промокоде:</b>
|
||||
• Использований: {promo.current_uses}/{promo.max_uses}
|
||||
• Статус: {'Активен' if promo.is_active else 'Неактивен'}
|
||||
|
||||
<b>⚠️ Внимание:</b> Это действие нельзя отменить!
|
||||
|
||||
ID: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=f"promo_delete_confirm_{promo.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_promocode_confirmed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
code = promo.code
|
||||
success = await delete_promocode(db, promo)
|
||||
|
||||
if success:
|
||||
await callback.answer(f"✅ Промокод {code} удален", show_alert=True)
|
||||
await show_promocodes_list(callback, db_user, db)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка удаления промокода", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocode_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
stats = await get_promocode_statistics(db, promo_id)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика промокода</b> <code>{promo.code}</code>
|
||||
|
||||
📈 <b>Общая статистика:</b>
|
||||
- Всего использований: {stats['total_uses']}
|
||||
- Использований сегодня: {stats['today_uses']}
|
||||
- Осталось использований: {promo.max_uses - promo.current_uses}
|
||||
|
||||
📅 <b>Последние использования:</b>
|
||||
"""
|
||||
|
||||
if stats['recent_uses']:
|
||||
for use in stats['recent_uses'][:5]:
|
||||
use_date = format_datetime(use.used_at)
|
||||
|
||||
if hasattr(use, 'user_username') and use.user_username:
|
||||
user_display = f"@{use.user_username}"
|
||||
elif hasattr(use, 'user_full_name') and use.user_full_name:
|
||||
user_display = use.user_full_name
|
||||
elif hasattr(use, 'user_telegram_id'):
|
||||
user_display = f"ID{use.user_telegram_id}"
|
||||
else:
|
||||
user_display = f"ID{use.user_id}"
|
||||
|
||||
text += f"- {use_date} | {user_display}\n"
|
||||
else:
|
||||
text += "- Пока не было использований\n"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_general_promocode_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
total_codes = await get_promocodes_count(db)
|
||||
active_codes = await get_promocodes_count(db, is_active=True)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Общая статистика промокодов</b>
|
||||
|
||||
📈 <b>Основные показатели:</b>
|
||||
- Всего промокодов: {total_codes}
|
||||
- Активных: {active_codes}
|
||||
- Неактивных: {total_codes - active_codes}
|
||||
|
||||
Для детальной статистики выберите конкретный промокод из списка.
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promo_list")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes")
|
||||
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
|
||||
@@ -552,11 +1010,24 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
|
||||
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
|
||||
dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_"))
|
||||
dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_"))
|
||||
dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_"))
|
||||
|
||||
dp.callback_query.register(start_edit_promocode_date, F.data.startswith("promo_edit_date_"))
|
||||
dp.callback_query.register(start_edit_promocode_amount, F.data.startswith("promo_edit_amount_"))
|
||||
dp.callback_query.register(start_edit_promocode_days, F.data.startswith("promo_edit_days_"))
|
||||
dp.callback_query.register(start_edit_promocode_uses, F.data.startswith("promo_edit_uses_"))
|
||||
dp.callback_query.register(show_general_promocode_stats, F.data == "admin_promo_general_stats")
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promocode_edit_menu,
|
||||
F.data.regexp(r"^promo_edit_\d+$")
|
||||
)
|
||||
|
||||
dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_"))
|
||||
dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_"))
|
||||
|
||||
dp.message.register(process_promocode_code, AdminStates.creating_promocode)
|
||||
dp.message.register(process_promocode_value, AdminStates.setting_promocode_value)
|
||||
dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses)
|
||||
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
|
||||
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
@@ -19,54 +20,197 @@ async def show_referral_statistics(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_referral_statistics(db)
|
||||
|
||||
avg_per_referrer = 0
|
||||
if stats['active_referrers'] > 0:
|
||||
avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers']
|
||||
|
||||
text = f"""
|
||||
try:
|
||||
stats = await get_referral_statistics(db)
|
||||
|
||||
avg_per_referrer = 0
|
||||
if stats.get('active_referrers', 0) > 0:
|
||||
avg_per_referrer = stats.get('total_paid_kopeks', 0) / stats['active_referrers']
|
||||
|
||||
current_time = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Пользователей с рефералами: {stats['users_with_referrals']}
|
||||
- Активных рефереров: {stats['active_referrers']}
|
||||
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
|
||||
- Пользователей с рефералами: {stats.get('users_with_referrals', 0)}
|
||||
- Активных рефереров: {stats.get('active_referrers', 0)}
|
||||
- Выплачено всего: {settings.format_price(stats.get('total_paid_kopeks', 0))}
|
||||
|
||||
<b>За период:</b>
|
||||
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
|
||||
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
|
||||
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
|
||||
- Сегодня: {settings.format_price(stats.get('today_earnings_kopeks', 0))}
|
||||
- За неделю: {settings.format_price(stats.get('week_earnings_kopeks', 0))}
|
||||
- За месяц: {settings.format_price(stats.get('month_earnings_kopeks', 0))}
|
||||
|
||||
<b>Средние показатели:</b>
|
||||
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
|
||||
|
||||
<b>Топ-5 рефереров:</b>
|
||||
"""
|
||||
|
||||
for i, referrer in enumerate(stats['top_referrers'][:5], 1):
|
||||
text += f"{i}. ID {referrer['user_id']}: {settings.format_price(referrer['total_earned_kopeks'])} ({referrer['referrals_count']} реф.)\n"
|
||||
|
||||
if not stats['top_referrers']:
|
||||
text += "Нет данных\n"
|
||||
|
||||
text += f"""
|
||||
|
||||
top_referrers = stats.get('top_referrers', [])
|
||||
if top_referrers:
|
||||
for i, referrer in enumerate(top_referrers[:5], 1):
|
||||
earned = referrer.get('total_earned_kopeks', 0)
|
||||
count = referrer.get('referrals_count', 0)
|
||||
user_id = referrer.get('user_id', 'N/A')
|
||||
|
||||
if count > 0:
|
||||
text += f"{i}. ID {user_id}: {settings.format_price(earned)} ({count} реф.)\n"
|
||||
else:
|
||||
logger.warning(f"Реферер {user_id} имеет {count} рефералов, но есть в топе")
|
||||
else:
|
||||
text += "Нет данных\n"
|
||||
|
||||
text += f"""
|
||||
|
||||
<b>Настройки:</b>
|
||||
- Бонус за регистрацию: {settings.format_price(settings.REFERRAL_REGISTRATION_REWARD)}
|
||||
<b>Настройки реферальной системы:</b>
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Комиссия: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
<i>🕐 Обновлено: {current_time}</i>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
|
||||
[types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")],
|
||||
[types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("Обновлено")
|
||||
except Exception as edit_error:
|
||||
if "message is not modified" in str(edit_error):
|
||||
await callback.answer("Данные актуальны")
|
||||
else:
|
||||
logger.error(f"Ошибка редактирования сообщения: {edit_error}")
|
||||
await callback.answer("Ошибка обновления")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_referral_statistics: {e}", exc_info=True)
|
||||
|
||||
current_time = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
❌ <b>Ошибка загрузки данных</b>
|
||||
|
||||
<b>Текущие настройки:</b>
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_referrals")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
except:
|
||||
pass
|
||||
await callback.answer("Произошла ошибка при загрузке статистики")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_top_referrers(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_referral_statistics(db)
|
||||
top_referrers = stats.get('top_referrers', [])
|
||||
|
||||
text = "🏆 <b>Топ рефереров</b>\n\n"
|
||||
|
||||
if top_referrers:
|
||||
for i, referrer in enumerate(top_referrers[:20], 1):
|
||||
earned = referrer.get('total_earned_kopeks', 0)
|
||||
count = referrer.get('referrals_count', 0)
|
||||
display_name = referrer.get('display_name', 'N/A')
|
||||
username = referrer.get('username', '')
|
||||
telegram_id = referrer.get('telegram_id', 'N/A')
|
||||
|
||||
if username:
|
||||
display_text = f"@{username} (ID{telegram_id})"
|
||||
elif display_name and display_name != f"ID{telegram_id}":
|
||||
display_text = f"{display_name} (ID{telegram_id})"
|
||||
else:
|
||||
display_text = f"ID{telegram_id}"
|
||||
|
||||
emoji = ""
|
||||
if i == 1:
|
||||
emoji = "🥇 "
|
||||
elif i == 2:
|
||||
emoji = "🥈 "
|
||||
elif i == 3:
|
||||
emoji = "🥉 "
|
||||
|
||||
text += f"{emoji}{i}. {display_text}\n"
|
||||
text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
|
||||
else:
|
||||
text += "Нет данных о рефererах\n"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
|
||||
await callback.answer("Ошибка загрузки топа рефереров")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = f"""
|
||||
⚙️ <b>Настройки реферальной системы</b>
|
||||
|
||||
<b>Бонусы и награды:</b>
|
||||
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<b>Уведомления:</b>
|
||||
• Статус: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
• Попытки отправки: {getattr(settings, 'REFERRAL_NOTIFICATION_RETRY_ATTEMPTS', 3)}
|
||||
|
||||
<i>💡 Для изменения настроек отредактируйте файл .env и перезапустите бота</i>
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
|
||||
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
|
||||
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
|
||||
|
||||
@@ -1322,53 +1322,22 @@ async def show_sync_options(
|
||||
text = """
|
||||
🔄 <b>Синхронизация с RemnaWave</b>
|
||||
|
||||
Выберите тип синхронизации:
|
||||
|
||||
🔄 <b>Синхронизировать всех</b>
|
||||
• Полная синхронизация всех пользователей
|
||||
• Создание новых пользователей из панели
|
||||
• Обновление данных существующих
|
||||
• Удаление неактуальных подписок
|
||||
🔄 <b>Полная синхронизация выполняет:</b>
|
||||
• Создание новых пользователей из панели в боте
|
||||
• Обновление данных существующих пользователей
|
||||
• Деактивация подписок пользователей, отсутствующих в панели
|
||||
• Сохранение балансов пользователей
|
||||
• ⏱️ Время выполнения: 2-5 минут
|
||||
|
||||
🆕 <b>Только новых</b>
|
||||
• Создание пользователей из панели, которых нет в боте
|
||||
• Быстрое добавление при массовой регистрации
|
||||
• ⏱️ Время выполнения: 30 секунд - 2 минуты
|
||||
|
||||
📈 <b>Обновить данные</b>
|
||||
• Обновление информации о трафике и подписках
|
||||
• Синхронизация статуса и лимитов
|
||||
• Обновление подключенных сквадов
|
||||
• ⏱️ Время выполнения: 1-3 минуты
|
||||
|
||||
🔍 <b>Валидация подписок</b>
|
||||
• Проверка и исправление проблем в данных
|
||||
• Восстановление отсутствующих полей
|
||||
• Исправление некорректных статусов
|
||||
|
||||
🧹 <b>Мягкая очистка</b>
|
||||
• Деактивация подписок отсутствующих в панели
|
||||
• Сохранение транзакций и истории
|
||||
|
||||
🗑️ <b>ПРИНУДИТЕЛЬНАЯ ОЧИСТКА</b>
|
||||
• ⚠️ ОПАСНО: Полное удаление данных пользователей
|
||||
• Удаление транзакций, балансов, рефералов
|
||||
• Только при серьезных проблемах синхронизации
|
||||
|
||||
⚠️ <b>Важно:</b>
|
||||
• Во время синхронизации не выполняйте другие операции
|
||||
• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы
|
||||
• Рекомендуется делать полную синхронизацию ежедневно
|
||||
• Баланс пользователей НЕ удаляется
|
||||
"""
|
||||
|
||||
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="sync_all_users")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
|
||||
]
|
||||
|
||||
@@ -1378,6 +1347,104 @@ async def show_sync_options(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_all_users(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Выполняет полную синхронизацию всех пользователей"""
|
||||
|
||||
progress_text = """
|
||||
🔄 <b>Выполняется полная синхронизация...</b>
|
||||
|
||||
📋 Этапы:
|
||||
• Загрузка ВСЕХ пользователей из панели RemnaWave
|
||||
• Создание новых пользователей в боте
|
||||
• Обновление существующих пользователей
|
||||
• Деактивация подписок отсутствующих пользователей
|
||||
• Сохранение балансов
|
||||
|
||||
⏳ Пожалуйста, подождите...
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(progress_text, reply_markup=None)
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
stats = await remnawave_service.sync_users_from_panel(db, "all")
|
||||
|
||||
total_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:
|
||||
status_emoji = "❌"
|
||||
status_text = "завершена с ошибками"
|
||||
|
||||
text = f"""
|
||||
{status_emoji} <b>Полная синхронизация {status_text}</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• 🆕 Создано: {stats['created']}
|
||||
• 🔄 Обновлено: {stats['updated']}
|
||||
• 🗑️ Деактивировано: {stats.get('deleted', 0)}
|
||||
• ❌ Ошибок: {stats['errors']}
|
||||
"""
|
||||
|
||||
if stats.get('deleted', 0) > 0:
|
||||
text += f"""
|
||||
|
||||
🗑️ <b>Деактивированные подписки:</b>
|
||||
Деактивированы подписки пользователей, которые
|
||||
отсутствуют в панели RemnaWave.
|
||||
💰 Балансы пользователей сохранены.
|
||||
"""
|
||||
|
||||
if stats['errors'] > 0:
|
||||
text += f"""
|
||||
|
||||
⚠️ <b>Внимание:</b>
|
||||
Некоторые операции завершились с ошибками.
|
||||
Проверьте логи для получения подробной информации.
|
||||
"""
|
||||
|
||||
text += f"""
|
||||
|
||||
💡 <b>Рекомендации:</b>
|
||||
• Полная синхронизация выполнена
|
||||
• Рекомендуется запускать раз в день
|
||||
• Все пользователи из панели синхронизированы
|
||||
"""
|
||||
|
||||
keyboard = []
|
||||
|
||||
if stats['errors'] > 0:
|
||||
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=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_sync_recommendations(
|
||||
@@ -1844,39 +1911,22 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(manage_node, F.data.startswith("node_restart_"))
|
||||
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(sync_all_users, F.data == "sync_all_users")
|
||||
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_"))
|
||||
|
||||
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_add_users_"))
|
||||
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_remove_users_"))
|
||||
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_delete_"))
|
||||
|
||||
dp.callback_query.register(show_squad_edit_menu, F.data.startswith("squad_edit_") & ~F.data.startswith("squad_edit_inbounds_"))
|
||||
|
||||
dp.callback_query.register(show_squad_inbounds_selection, F.data.startswith("squad_edit_inbounds_"))
|
||||
dp.callback_query.register(show_squad_rename_form, F.data.startswith("squad_rename_"))
|
||||
|
||||
dp.callback_query.register(cancel_squad_rename, F.data.startswith("cancel_rename_"))
|
||||
|
||||
dp.callback_query.register(toggle_squad_inbound, F.data.startswith("sqd_tgl_"))
|
||||
dp.callback_query.register(save_squad_inbounds, F.data.startswith("sqd_save_"))
|
||||
|
||||
dp.callback_query.register(show_squad_edit_menu_short, F.data.startswith("sqd_edit_"))
|
||||
|
||||
|
||||
dp.callback_query.register(start_squad_creation, F.data == "admin_squad_create")
|
||||
|
||||
dp.callback_query.register(cancel_squad_creation, F.data == "cancel_squad_create")
|
||||
|
||||
dp.callback_query.register(toggle_create_inbound, F.data.startswith("create_tgl_"))
|
||||
|
||||
dp.callback_query.register(finish_squad_creation, F.data == "create_squad_finish")
|
||||
|
||||
dp.message.register(
|
||||
|
||||
@@ -51,9 +51,6 @@ async def show_servers_menu(
|
||||
types.InlineKeyboardButton(text="📊 Синхронизировать счетчики", callback_data="admin_servers_sync_counts"),
|
||||
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="➕ Добавить сервер", callback_data="admin_servers_add")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
@@ -130,10 +127,6 @@ async def show_servers_list(
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="➕ Добавить", callback_data="admin_servers_add")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
|
||||
@@ -959,4 +952,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
|
||||
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
|
||||
|
||||
dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_"))
|
||||
dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_"))
|
||||
|
||||
@@ -50,6 +50,8 @@ async def show_users_statistics(
|
||||
total_users = stats['total_users']
|
||||
active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0)
|
||||
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
text = f"""
|
||||
👥 <b>Статистика пользователей</b>
|
||||
|
||||
@@ -66,16 +68,27 @@ async def show_users_statistics(
|
||||
<b>Активность:</b>
|
||||
- Коэффициент активности: {active_rate}
|
||||
- Рост за месяц: +{stats['new_month']} ({format_percentage(stats['new_month'] / total_users * 100 if total_users > 0 else 0)})
|
||||
|
||||
<b>Обновлено:</b> {current_time}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления статистики пользователей: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -89,6 +102,7 @@ async def show_subscriptions_statistics(
|
||||
|
||||
total_subs = stats['total_subscriptions']
|
||||
conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0)
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
text = f"""
|
||||
📱 <b>Статистика подписок</b>
|
||||
@@ -107,16 +121,24 @@ async def show_subscriptions_statistics(
|
||||
- Сегодня: {stats['purchased_today']}
|
||||
- За неделю: {stats['purchased_week']}
|
||||
- За месяц: {stats['purchased_month']}
|
||||
|
||||
<b>Обновлено:</b> {current_time}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления статистики подписок: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -131,6 +153,7 @@ async def show_revenue_statistics(
|
||||
|
||||
month_stats = await get_transactions_statistics(db, month_start, now)
|
||||
all_time_stats = await get_transactions_statistics(db)
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
text = f"""
|
||||
💰 <b>Статистика доходов</b>
|
||||
@@ -156,15 +179,23 @@ async def show_revenue_statistics(
|
||||
if method and data['count'] > 0:
|
||||
text += f"• {method}: {data['count']} ({settings.format_price(data['amount'])})\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
text += f"\n<b>Обновлено:</b> {current_time}"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
# [types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления статистики доходов: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -175,6 +206,7 @@ async def show_referral_statistics(
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_referral_statistics(db)
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
avg_per_referrer = 0
|
||||
if stats['active_referrers'] > 0:
|
||||
@@ -194,7 +226,7 @@ async def show_referral_statistics(
|
||||
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
|
||||
|
||||
<b>Средние показатели:</b>
|
||||
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
|
||||
- На одного рефререра: {settings.format_price(int(avg_per_referrer))}
|
||||
|
||||
<b>Топ рефереры:</b>
|
||||
"""
|
||||
@@ -208,14 +240,22 @@ async def show_referral_statistics(
|
||||
else:
|
||||
text += "Пока нет активных рефереров"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
text += f"\n<b>Обновлено:</b> {current_time}"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления реферальной статистики: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -232,6 +272,7 @@ async def show_summary_statistics(
|
||||
now = datetime.utcnow()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
revenue_stats = await get_transactions_statistics(db, month_start, now)
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
conversion_rate = 0
|
||||
if user_stats['total_users'] > 0:
|
||||
@@ -263,18 +304,23 @@ async def show_summary_statistics(
|
||||
- Пользователи: +{user_stats['new_month']} за месяц
|
||||
- Продажи: +{sub_stats['purchased_month']} за месяц
|
||||
|
||||
<b>Обновлено:</b> {format_datetime(datetime.utcnow())}
|
||||
<b>Обновлено:</b> {current_time}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления общей статистики: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -347,4 +393,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_revenue_by_period,
|
||||
F.data == f"period_{period}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -260,11 +260,11 @@ async def show_subscriptions_stats(
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"),
|
||||
types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"),
|
||||
# types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts")
|
||||
# ],
|
||||
# [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
]
|
||||
|
||||
@@ -306,13 +306,13 @@ async def show_pricing_settings(
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
# ],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
# ],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
@@ -405,8 +405,7 @@ async def show_countries_management(
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries"),
|
||||
types.InlineKeyboardButton(text="⚙️ API настройки", callback_data="admin_rw_api")
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"),
|
||||
@@ -495,4 +494,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
handle_subscriptions_pagination,
|
||||
F.data.startswith("admin_subs_list_page_")
|
||||
)
|
||||
)
|
||||
|
||||
@@ -16,6 +16,8 @@ from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -825,6 +827,8 @@ async def show_user_statistics(
|
||||
user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
referral_stats = await get_detailed_referral_stats(db, user.id)
|
||||
|
||||
text = f"📊 <b>Статистика пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
@@ -837,7 +841,7 @@ async def show_user_statistics(
|
||||
text += f"<b>Подписка:</b>\n"
|
||||
if subscription:
|
||||
sub_status = "✅ Активна" if subscription.is_active else "❌ Неактивна"
|
||||
sub_type = " (триал)" if subscription.is_trial 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"
|
||||
@@ -846,11 +850,39 @@ async def show_user_statistics(
|
||||
text += f"• Отсутствует\n"
|
||||
|
||||
text += f"\n<b>Реферальная программа:</b>\n"
|
||||
|
||||
if user.referred_by_id:
|
||||
text += f"• Пришел по рефералке\n"
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if referrer:
|
||||
text += f"• Пришел по реферальной ссылке от <b>{referrer.full_name}</b>\n"
|
||||
else:
|
||||
text += f"• Пришел по реферальной ссылке (реферер не найден)\n"
|
||||
else:
|
||||
text += f"• Прямая регистрация\n"
|
||||
text += f"• Реферальный код: <code>{user.referral_code}</code>\n"
|
||||
|
||||
text += f"• Реферальный код: <code>{user.referral_code}</code>\n\n"
|
||||
|
||||
if referral_stats['invited_count'] > 0:
|
||||
text += f"<b>Доходы от рефералов:</b>\n"
|
||||
text += f"• Всего приглашено: {referral_stats['invited_count']}\n"
|
||||
text += f"• Активных рефералов: {referral_stats['active_referrals']}\n"
|
||||
text += f"• Общий доход: {settings.format_price(referral_stats['total_earned_kopeks'])}\n"
|
||||
text += f"• Доход за месяц: {settings.format_price(referral_stats['month_earned_kopeks'])}\n"
|
||||
|
||||
if referral_stats['referrals_detail']:
|
||||
text += f"\n<b>Детали по рефералам:</b>\n"
|
||||
for detail in referral_stats['referrals_detail'][:5]:
|
||||
referral_name = detail['referral_name']
|
||||
earned = settings.format_price(detail['total_earned_kopeks'])
|
||||
status = "🟢" if detail['is_active'] else "🔴"
|
||||
text += f"• {status} {referral_name}: {earned}\n"
|
||||
|
||||
if len(referral_stats['referrals_detail']) > 5:
|
||||
text += f"• ... и еще {len(referral_stats['referrals_detail']) - 5} рефералов\n"
|
||||
else:
|
||||
text += f"<b>Реферальная программа:</b>\n"
|
||||
text += f"• Рефералов нет\n"
|
||||
text += f"• Доходов нет\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
@@ -860,6 +892,64 @@ async def show_user_statistics(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
from app.database.crud.referral import get_user_referral_stats, get_referral_earnings_by_user
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
base_stats = await get_user_referral_stats(db, user_id)
|
||||
|
||||
referrals_query = select(User).options(
|
||||
selectinload(User.subscription)
|
||||
).where(User.referred_by_id == user_id)
|
||||
|
||||
referrals_result = await db.execute(referrals_query)
|
||||
referrals = referrals_result.scalars().all()
|
||||
|
||||
earnings_by_referral = {}
|
||||
all_earnings = await get_referral_earnings_by_user(db, user_id)
|
||||
|
||||
for earning in all_earnings:
|
||||
referral_id = earning.referral_id
|
||||
if referral_id not in earnings_by_referral:
|
||||
earnings_by_referral[referral_id] = 0
|
||||
earnings_by_referral[referral_id] += earning.amount_kopeks
|
||||
|
||||
referrals_detail = []
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
for referral in referrals:
|
||||
earned = earnings_by_referral.get(referral.id, 0)
|
||||
|
||||
is_active = False
|
||||
if referral.subscription:
|
||||
from app.database.models import SubscriptionStatus
|
||||
is_active = (
|
||||
referral.subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
referral.subscription.end_date > current_time
|
||||
)
|
||||
|
||||
referrals_detail.append({
|
||||
'referral_id': referral.id,
|
||||
'referral_name': referral.full_name,
|
||||
'referral_telegram_id': referral.telegram_id,
|
||||
'total_earned_kopeks': earned,
|
||||
'is_active': is_active,
|
||||
'registration_date': referral.created_at,
|
||||
'has_subscription': bool(referral.subscription)
|
||||
})
|
||||
|
||||
referrals_detail.sort(key=lambda x: x['total_earned_kopeks'], reverse=True)
|
||||
|
||||
return {
|
||||
'invited_count': base_stats['invited_count'],
|
||||
'active_referrals': base_stats['active_referrals'],
|
||||
'total_earned_kopeks': base_stats['total_earned_kopeks'],
|
||||
'month_earned_kopeks': base_stats['month_earned_kopeks'],
|
||||
'referrals_detail': referrals_detail
|
||||
}
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def extend_user_subscription(
|
||||
@@ -1300,6 +1390,642 @@ async def process_subscription_grant_text(
|
||||
|
||||
await state.clear()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_servers_management(
|
||||
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"
|
||||
|
||||
if subscription:
|
||||
current_squads = subscription.connected_squads or []
|
||||
|
||||
if current_squads:
|
||||
text += f"<b>Текущие серверы ({len(current_squads)}):</b>\n"
|
||||
|
||||
for squad_uuid in current_squads:
|
||||
try:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server:
|
||||
text += f"• {server.display_name}\n"
|
||||
else:
|
||||
text += f"• {squad_uuid[:8]}... (неизвестный)\n"
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сервера {squad_uuid}: {e}")
|
||||
text += f"• {squad_uuid[:8]}... (ошибка загрузки)\n"
|
||||
else:
|
||||
text += "<b>Серверы:</b> Не подключены\n"
|
||||
|
||||
text += f"\n<b>Устройства:</b> {subscription.device_limit}\n"
|
||||
traffic_display = f"{subscription.traffic_used_gb:.1f}/"
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
traffic_display += "∞ ГБ"
|
||||
else:
|
||||
traffic_display += f"{subscription.traffic_limit_gb} ГБ"
|
||||
text += f"<b>Трафик:</b> {traffic_display}\n"
|
||||
else:
|
||||
text += "❌ <b>Подписка отсутствует</b>"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Сменить сервер", callback_data=f"admin_user_change_server_{user_id}"),
|
||||
types.InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_user_devices_{user_id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_user_traffic_{user_id}"),
|
||||
types.InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data=f"admin_user_reset_devices_{user_id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
await _show_servers_for_user(callback, user_id, db)
|
||||
await callback.answer()
|
||||
|
||||
async def _show_servers_for_user(
|
||||
callback: types.CallbackQuery,
|
||||
user_id: int,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
current_squads = []
|
||||
if user and user.subscription:
|
||||
current_squads = user.subscription.connected_squads or []
|
||||
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=False)
|
||||
|
||||
servers_to_show = []
|
||||
for server in all_servers:
|
||||
if server.is_available or server.squad_uuid in current_squads:
|
||||
servers_to_show.append(server)
|
||||
|
||||
if not servers_to_show:
|
||||
await callback.message.edit_text(
|
||||
"❌ Доступные серверы не найдены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
text = f"🌍 <b>Управление серверами</b>\n\n"
|
||||
text += f"Нажмите на сервер чтобы добавить/убрать:\n"
|
||||
text += f"✅ - выбранный сервер\n"
|
||||
text += f"⚪ - доступный сервер\n"
|
||||
text += f"🔒 - неактивный (только для уже назначенных)\n\n"
|
||||
|
||||
keyboard = []
|
||||
selected_servers = [s for s in servers_to_show if s.squad_uuid in current_squads]
|
||||
available_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and s.is_available]
|
||||
inactive_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and not s.is_available]
|
||||
|
||||
sorted_servers = selected_servers + available_servers + inactive_servers
|
||||
|
||||
for server in sorted_servers[:20]:
|
||||
is_selected = server.squad_uuid in current_squads
|
||||
|
||||
if is_selected:
|
||||
emoji = "✅"
|
||||
elif server.is_available:
|
||||
emoji = "⚪"
|
||||
else:
|
||||
emoji = "🔒"
|
||||
|
||||
display_name = server.display_name
|
||||
if not server.is_available and not is_selected:
|
||||
display_name += " (неактивный)"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{emoji} {display_name}",
|
||||
callback_data=f"admin_user_toggle_server_{user_id}_{server.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(servers_to_show) > 20:
|
||||
text += f"\n📝 Показано первых 20 из {len(servers_to_show)} серверов"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка показа серверов: {e}")
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_server(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[4])
|
||||
server_id = int(parts[5])
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.subscription:
|
||||
await callback.answer("❌ Пользователь или подписка не найдены", show_alert=True)
|
||||
return
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
|
||||
if server.squad_uuid in current_squads:
|
||||
current_squads.remove(server.squad_uuid)
|
||||
action_text = "удален"
|
||||
else:
|
||||
current_squads.append(server.squad_uuid)
|
||||
action_text = "добавлен"
|
||||
|
||||
subscription.connected_squads = current_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
active_internal_squads=current_squads
|
||||
)
|
||||
logger.info(f"✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
logger.error(f"❌ Ошибка обновления RemnaWave: {rw_error}")
|
||||
|
||||
logger.info(f"Админ {db_user.id}: сервер {server.display_name} {action_text} для пользователя {user_id}")
|
||||
|
||||
await refresh_server_selection_screen(callback, user_id, db_user, db)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка переключения сервера: {e}")
|
||||
await callback.answer("❌ Ошибка изменения сервера", show_alert=True)
|
||||
|
||||
async def refresh_server_selection_screen(
|
||||
callback: types.CallbackQuery,
|
||||
user_id: int,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
current_squads = []
|
||||
if user and user.subscription:
|
||||
current_squads = user.subscription.connected_squads or []
|
||||
|
||||
servers, _ = await get_all_server_squads(db, available_only=True)
|
||||
|
||||
if not servers:
|
||||
await callback.message.edit_text(
|
||||
"❌ Доступные серверы не найдены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
text = f"🌍 <b>Управление серверами</b>\n\n"
|
||||
text += f"Нажмите на сервер чтобы добавить/убрать:\n\n"
|
||||
|
||||
keyboard = []
|
||||
for server in servers[:15]:
|
||||
is_selected = server.squad_uuid in current_squads
|
||||
emoji = "✅" if is_selected else "⚪"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{emoji} {server.display_name}",
|
||||
callback_data=f"admin_user_toggle_server_{user_id}_{server.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(servers) > 15:
|
||||
text += f"\n📝 Показано первых 15 из {len(servers)} серверов"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления экрана серверов: {e}")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_devices_edit(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
await state.update_data(editing_devices_user_id=user_id)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📱 <b>Изменение количества устройств</b>\n\n"
|
||||
"Введите новое количество устройств (от 1 до 10):\n"
|
||||
"• Текущее значение будет заменено\n"
|
||||
"• Примеры: 1, 2, 5, 10\n\n"
|
||||
"Или нажмите /cancel для отмены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="1", callback_data=f"admin_user_devices_set_{user_id}_1"),
|
||||
types.InlineKeyboardButton(text="2", callback_data=f"admin_user_devices_set_{user_id}_2"),
|
||||
types.InlineKeyboardButton(text="3", callback_data=f"admin_user_devices_set_{user_id}_3")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="5", callback_data=f"admin_user_devices_set_{user_id}_5"),
|
||||
types.InlineKeyboardButton(text="10", callback_data=f"admin_user_devices_set_{user_id}_10")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_devices)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_user_devices_button(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[-2])
|
||||
devices = int(parts[-1])
|
||||
|
||||
success = await _update_user_devices(db, user_id, devices, db_user.id)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Количество устройств изменено на: {devices}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка изменения количества устройств",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_devices_edit_text(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
user_id = data.get("editing_devices_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer("❌ Ошибка: пользователь не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
devices = int(message.text.strip())
|
||||
|
||||
if devices <= 0 or devices > 10:
|
||||
await message.answer("❌ Количество устройств должно быть от 1 до 10")
|
||||
return
|
||||
|
||||
success = await _update_user_devices(db, user_id, devices, db_user.id)
|
||||
|
||||
if success:
|
||||
await message.answer(
|
||||
f"✅ Количество устройств изменено на: {devices}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка изменения количества устройств")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число устройств")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_traffic_edit(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
await state.update_data(editing_traffic_user_id=user_id)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📊 <b>Изменение лимита трафика</b>\n\n"
|
||||
"Введите новый лимит трафика в ГБ:\n"
|
||||
"• 0 - безлимитный трафик\n"
|
||||
"• Примеры: 50, 100, 500, 1000\n"
|
||||
"• Максимум: 10000 ГБ\n\n"
|
||||
"Или нажмите /cancel для отмены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="50 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_50"),
|
||||
types.InlineKeyboardButton(text="100 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_100")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="500 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_500"),
|
||||
types.InlineKeyboardButton(text="1000 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_1000")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_user_traffic_set_{user_id}_0")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_traffic)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_user_traffic_button(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[-2])
|
||||
traffic_gb = int(parts[-1])
|
||||
|
||||
success = await _update_user_traffic(db, user_id, traffic_gb, db_user.id)
|
||||
|
||||
if success:
|
||||
traffic_text = "♾️ безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ"
|
||||
await callback.message.edit_text(
|
||||
f"✅ Лимит трафика изменен на: {traffic_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка изменения лимита трафика",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_traffic_edit_text(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
user_id = data.get("editing_traffic_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer("❌ Ошибка: пользователь не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
traffic_gb = int(message.text.strip())
|
||||
|
||||
if traffic_gb < 0 or traffic_gb > 10000:
|
||||
await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ (0 = безлимит)")
|
||||
return
|
||||
|
||||
success = await _update_user_traffic(db, user_id, traffic_gb, db_user.id)
|
||||
|
||||
if success:
|
||||
traffic_text = "♾️ безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ"
|
||||
await message.answer(
|
||||
f"✅ Лимит трафика изменен на: {traffic_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка изменения лимита трафика")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число ГБ")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_reset_devices(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 <b>Сброс устройств пользователя</b>\n\n"
|
||||
"⚠️ <b>ВНИМАНИЕ!</b>\n"
|
||||
"Вы уверены, что хотите сбросить все HWID устройства этого пользователя?\n\n"
|
||||
"Это действие:\n"
|
||||
"• Удалит все привязанные устройства\n"
|
||||
"• Пользователь сможет заново подключить устройства\n"
|
||||
"• Действие необратимо!\n\n"
|
||||
"Продолжить?",
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
f"admin_user_reset_devices_confirm_{user_id}",
|
||||
f"admin_user_servers_{user_id}",
|
||||
db_user.language
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_user_devices(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
await callback.answer("❌ Пользователь не найден или не связан с RemnaWave", show_alert=True)
|
||||
return
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
success = await api.reset_user_devices(user.remnawave_uuid)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
"✅ Устройства пользователя успешно сброшены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
logger.info(f"Админ {db_user.id} сбросил устройства пользователя {user_id}")
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка сброса устройств",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сброса устройств: {e}")
|
||||
await callback.answer("❌ Ошибка сброса устройств", show_alert=True)
|
||||
|
||||
async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, admin_id: int) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.subscription:
|
||||
logger.error(f"Пользователь {user_id} или подписка не найдены")
|
||||
return False
|
||||
|
||||
subscription = user.subscription
|
||||
old_devices = subscription.device_limit
|
||||
subscription.device_limit = devices
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
hwid_device_limit=devices
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
logger.error(f"❌ Ошибка обновления лимита устройств в RemnaWave: {rw_error}")
|
||||
|
||||
logger.info(f"Админ {admin_id} изменил лимит устройств пользователя {user_id}: {old_devices} -> {devices}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления лимита устройств: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int, admin_id: int) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.subscription:
|
||||
logger.error(f"Пользователь {user_id} или подписка не найдены")
|
||||
return False
|
||||
|
||||
subscription = user.subscription
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
subscription.traffic_limit_gb = traffic_gb
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
logger.error(f"❌ Ошибка обновления лимита трафика в RemnaWave: {rw_error}")
|
||||
|
||||
traffic_text_old = "безлимитный" if old_traffic == 0 else f"{old_traffic} ГБ"
|
||||
traffic_text_new = "безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ"
|
||||
logger.info(f"Админ {admin_id} изменил лимит трафика пользователя {user_id}: {traffic_text_old} -> {traffic_text_new}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления лимита трафика: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool:
|
||||
try:
|
||||
@@ -1648,3 +2374,58 @@ def register_handlers(dp: Dispatcher):
|
||||
process_subscription_grant_text,
|
||||
AdminStates.granting_subscription
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_servers_management,
|
||||
F.data.startswith("admin_user_servers_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_server_selection,
|
||||
F.data.startswith("admin_user_change_server_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_user_server,
|
||||
F.data.startswith("admin_user_toggle_server_") & ~F.data.endswith("_add") & ~F.data.endswith("_remove")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_devices_edit,
|
||||
F.data.startswith("admin_user_devices_") & ~F.data.contains("set")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_devices_button,
|
||||
F.data.startswith("admin_user_devices_set_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_devices_edit_text,
|
||||
AdminStates.editing_user_devices
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_traffic_edit,
|
||||
F.data.startswith("admin_user_traffic_") & ~F.data.contains("set")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_traffic_button,
|
||||
F.data.startswith("admin_user_traffic_set_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_traffic_edit_text,
|
||||
AdminStates.editing_user_traffic
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
confirm_reset_devices,
|
||||
F.data.startswith("admin_user_reset_devices_") & ~F.data.contains("confirm")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
reset_user_devices,
|
||||
F.data.startswith("admin_user_reset_devices_confirm_")
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
@@ -126,7 +157,7 @@ async def show_payment_methods(
|
||||
Выберите удобный для вас способ оплаты:
|
||||
|
||||
⭐ <b>Telegram Stars</b> - быстро и удобно
|
||||
💎 <b>Банковская карта</b> - через Tribute
|
||||
💳 <b>Банковская карта</b> - через YooKassa/Tribute
|
||||
🛠️ <b>Через поддержку</b> - другие способы
|
||||
|
||||
Выберите способ пополнения:
|
||||
@@ -162,6 +193,30 @@ async def start_stars_payment(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_yookassa_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 50,000 рублей:",
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="yookassa")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -265,11 +320,11 @@ async def process_topup_amount(
|
||||
amount_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer("❌ Минимальная сумма пополнения: 1 ₽")
|
||||
await message.answer("Минимальная сумма пополнения: 1 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer("❌ Максимальная сумма пополнения: 50,000 ₽")
|
||||
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
|
||||
return
|
||||
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
@@ -278,8 +333,12 @@ async def process_topup_amount(
|
||||
|
||||
if payment_method == "stars":
|
||||
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
||||
elif payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
await message.answer("❌ Неизвестный способ оплаты")
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
@@ -287,7 +346,6 @@ async def process_topup_amount(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_stars_payment_amount(
|
||||
message: types.Message,
|
||||
@@ -298,10 +356,15 @@ async def process_stars_payment_amount(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("❌ Оплата Stars временно недоступна")
|
||||
await message.answer("⚠ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
try:
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
@@ -316,7 +379,9 @@ async def process_stars_payment_amount(
|
||||
|
||||
await message.answer(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"Сумма: {texts.format_price(amount_kopeks)}\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
f"📊 Курс: {settings.get_stars_rate():.2f}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
@@ -326,7 +391,141 @@ async def process_stars_payment_amount(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа")
|
||||
await message.answer("⚠ Ошибка создания платежа")
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_yookassa_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
await message.answer("❌ Оплата через YooKassa временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < 10000:
|
||||
await message.answer("❌ Минимальная сумма для оплаты картой: 100 ₽")
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
"user_telegram_id": str(db_user.telegram_id),
|
||||
"user_username": db_user.username or "",
|
||||
"purpose": "balance_topup"
|
||||
}
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
if not confirmation_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Оплатить картой", url=confirmation_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить картой'\n"
|
||||
f"2. Введите данные вашей карты\n"
|
||||
f"3. Подтвердите платеж\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks/100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания YooKassa платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
|
||||
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = {
|
||||
"pending": "⏳",
|
||||
"waiting_for_capture": "⌛",
|
||||
"succeeded": "✅",
|
||||
"canceled": "❌",
|
||||
"failed": "❌"
|
||||
}
|
||||
|
||||
status_text = {
|
||||
"pending": "Ожидает оплаты",
|
||||
"waiting_for_capture": "Ожидает подтверждения",
|
||||
"succeeded": "Оплачен",
|
||||
"canceled": "Отменен",
|
||||
"failed": "Ошибка"
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(payment.status, "❓")
|
||||
status = status_text.get(payment.status, "Неизвестно")
|
||||
|
||||
message_text = (f"💳 Статус платежа:\n\n"
|
||||
f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
f"📊 Статус: {emoji} {status}\n"
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
|
||||
|
||||
if payment.is_succeeded:
|
||||
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_failed:
|
||||
message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}"
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
@@ -356,6 +555,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "topup_stars"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_yookassa_payment,
|
||||
F.data == "topup_yookassa"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_tribute_payment,
|
||||
F.data == "topup_tribute"
|
||||
@@ -366,6 +570,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "topup_support"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_topup_amount,
|
||||
BalanceStates.waiting_for_amount
|
||||
|
||||
@@ -19,7 +19,6 @@ async def show_main_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from datetime import datetime
|
||||
@@ -34,7 +33,6 @@ async def show_main_menu(
|
||||
|
||||
menu_text = texts.MAIN_MENU.format(
|
||||
user_name=db_user.full_name,
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
subscription_status=_get_subscription_status(db_user, texts)
|
||||
)
|
||||
|
||||
@@ -45,7 +43,8 @@ async def show_main_menu(
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -109,7 +108,7 @@ async def handle_back_to_menu(
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
return "⌐ Отсутствует"
|
||||
return "❌ Отсутствует"
|
||||
|
||||
subscription = user.subscription
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
@@ -3,11 +3,10 @@ from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral import get_referral_earnings_sum
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard, get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.user_utils import get_user_referral_summary
|
||||
from app.utils.user_utils import get_user_referral_summary, get_detailed_referral_list, get_referral_analytics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,7 +16,6 @@ async def show_referral_info(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
@@ -28,30 +26,58 @@ async def show_referral_info(
|
||||
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
|
||||
|
||||
referral_text += f"📊 <b>Ваша статистика:</b>\n"
|
||||
referral_text += f"• Приглашено пользователей: {summary['invited_count']}\n"
|
||||
referral_text += f"• Купили подписку: {summary['paid_referrals_count']}\n"
|
||||
referral_text += f"• Заработано всего: {texts.format_price(summary['total_earned_kopeks'])}\n"
|
||||
referral_text += f"• За последний месяц: {texts.format_price(summary['month_earned_kopeks'])}\n\n"
|
||||
referral_text += f"• Приглашено пользователей: <b>{summary['invited_count']}</b>\n"
|
||||
referral_text += f"• Сделали первое пополнение: <b>{summary['paid_referrals_count']}</b>\n"
|
||||
referral_text += f"• Активных рефералов: <b>{summary['active_referrals_count']}</b>\n"
|
||||
referral_text += f"• Конверсия: <b>{summary['conversion_rate']}%</b>\n"
|
||||
referral_text += f"• Заработано всего: <b>{texts.format_price(summary['total_earned_kopeks'])}</b>\n"
|
||||
referral_text += f"• За последний месяц: <b>{texts.format_price(summary['month_earned_kopeks'])}</b>\n\n"
|
||||
|
||||
referral_text += f"🎁 <b>Как работают награды:</b>\n"
|
||||
referral_text += f"• Новый пользователь получает: {texts.format_price(settings.REFERRED_USER_REWARD)}\n"
|
||||
referral_text += f"• Вы получаете при первой покупке реферала: {texts.format_price(settings.REFERRAL_REGISTRATION_REWARD)}\n"
|
||||
referral_text += f"• Комиссия с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%\n\n"
|
||||
referral_text += f"• Новый пользователь получает: <b>{texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}</b> при первом пополнении от <b>{texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}</b>\n"
|
||||
referral_text += f"• Вы получаете при первом пополнении реферала: <b>{texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}</b>\n"
|
||||
referral_text += f"• Комиссия с каждого пополнения реферала: <b>{settings.REFERRAL_COMMISSION_PERCENT}%</b>\n\n"
|
||||
|
||||
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
|
||||
referral_text += f"<code>{referral_link}</code>\n\n"
|
||||
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
|
||||
|
||||
if summary['recent_earnings']:
|
||||
referral_text += f"💰 <b>Последние начисления:</b>\n"
|
||||
for earning in summary['recent_earnings'][:3]:
|
||||
reason_text = {
|
||||
"referral_first_purchase": "🎉 Первая покупка",
|
||||
"referral_commission": "💰 Комиссия",
|
||||
"referral_registration_pending": "⏳ Ожидание покупки"
|
||||
}.get(earning['reason'], earning['reason'])
|
||||
|
||||
referral_text += f"• {reason_text}: {texts.format_price(earning['amount_kopeks'])} от {earning['referral_name']}\n"
|
||||
meaningful_earnings = [
|
||||
earning for earning in summary['recent_earnings'][:5]
|
||||
if earning['amount_kopeks'] > 0
|
||||
]
|
||||
|
||||
if meaningful_earnings:
|
||||
referral_text += f"💰 <b>Последние начисления:</b>\n"
|
||||
for earning in meaningful_earnings[:3]:
|
||||
reason_text = {
|
||||
"referral_first_topup": "🎉 Первое пополнение",
|
||||
"referral_commission_topup": "💰 Комиссия с пополнения",
|
||||
"referral_commission": "💰 Комиссия с покупки"
|
||||
}.get(earning['reason'], earning['reason'])
|
||||
|
||||
referral_text += f"• {reason_text}: <b>{texts.format_price(earning['amount_kopeks'])}</b> от {earning['referral_name']}\n"
|
||||
referral_text += "\n"
|
||||
|
||||
if summary['earnings_by_type']:
|
||||
referral_text += f"📈 <b>Доходы по типам:</b>\n"
|
||||
|
||||
if 'referral_first_topup' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_first_topup']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Бонусы за первые пополнения: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
if 'referral_commission_topup' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_commission_topup']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Комиссии с пополнений: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
if 'referral_commission' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_commission']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Комиссии с покупок: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
referral_text += "\n"
|
||||
|
||||
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
|
||||
@@ -64,47 +90,141 @@ async def show_referral_info(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_detailed_referral_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
referrals_data = await get_detailed_referral_list(db, db_user.id, limit=10, offset=(page - 1) * 10)
|
||||
|
||||
if not referrals_data['referrals']:
|
||||
await callback.message.edit_text(
|
||||
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"👥 <b>Ваши рефералы</b> (стр. {referrals_data['current_page']}/{referrals_data['total_pages']})\n\n"
|
||||
|
||||
for i, referral in enumerate(referrals_data['referrals'], 1):
|
||||
status_emoji = "🟢" if referral['status'] == 'active' else "🔴"
|
||||
|
||||
topup_emoji = "💰" if referral['has_made_first_topup'] else "⏳"
|
||||
|
||||
text += f"{i}. {status_emoji} <b>{referral['full_name']}</b>\n"
|
||||
text += f" {topup_emoji} Пополнений: {referral['topups_count']}\n"
|
||||
text += f" 💎 Заработано с него: {texts.format_price(referral['total_earned_kopeks'])}\n"
|
||||
text += f" 📅 Регистрация: {referral['days_since_registration']} дн. назад\n"
|
||||
|
||||
if referral['days_since_activity'] is not None:
|
||||
text += f" 🕐 Активность: {referral['days_since_activity']} дн. назад\n"
|
||||
else:
|
||||
text += f" 🕐 Активность: давно\n"
|
||||
|
||||
text += "\n"
|
||||
|
||||
keyboard = []
|
||||
nav_buttons = []
|
||||
|
||||
if referrals_data['has_prev']:
|
||||
nav_buttons.append(types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"referral_list_page_{page - 1}"
|
||||
))
|
||||
|
||||
if referrals_data['has_next']:
|
||||
nav_buttons.append(types.InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=f"referral_list_page_{page + 1}"
|
||||
))
|
||||
|
||||
if nav_buttons:
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_referral_analytics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
analytics = await get_referral_analytics(db, db_user.id)
|
||||
|
||||
text = f"📊 <b>Аналитика рефералов</b>\n\n"
|
||||
|
||||
text += f"💰 <b>Доходы по периодам:</b>\n"
|
||||
text += f"• Сегодня: {texts.format_price(analytics['earnings_by_period']['today'])}\n"
|
||||
text += f"• За неделю: {texts.format_price(analytics['earnings_by_period']['week'])}\n"
|
||||
text += f"• За месяц: {texts.format_price(analytics['earnings_by_period']['month'])}\n"
|
||||
text += f"• За квартал: {texts.format_price(analytics['earnings_by_period']['quarter'])}\n\n"
|
||||
|
||||
if analytics['top_referrals']:
|
||||
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\n"
|
||||
for i, ref in enumerate(analytics['top_referrals'], 1):
|
||||
text += f"{i}. {ref['referral_name']}: {texts.format_price(ref['total_earned_kopeks'])} ({ref['earnings_count']} начислений)\n"
|
||||
text += "\n"
|
||||
|
||||
text += "📈 Продолжайте развивать свою реферальную сеть!"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def create_invite_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n"
|
||||
invite_text += f"💎 При регистрации по моей ссылке ты получишь {texts.format_price(settings.REFERRED_USER_REWARD)} на баланс!\n\n"
|
||||
invite_text += f"💎 При первом пополнении от {texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} ты получишь {texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)} бонусом на баланс!\n\n"
|
||||
invite_text += f"🚀 Быстрое подключение\n"
|
||||
invite_text += f"🌍 Серверы по всему миру\n"
|
||||
invite_text += f"🔒 Надежная защита\n\n"
|
||||
invite_text += f"👇 Переходи по ссылке:\n{referral_link}"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📤 Поделиться",
|
||||
switch_inline_query=invite_text
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📋 Скопировать ссылку",
|
||||
callback_data="copy_referral_link"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)
|
||||
]
|
||||
[types.InlineKeyboardButton(
|
||||
text="📤 Поделиться",
|
||||
switch_inline_query=invite_text
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Используйте кнопку ниже для отправки приглашения или скопируйте текст:\n\n"
|
||||
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
|
||||
f"<code>{invite_text}</code>",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
@@ -112,20 +232,6 @@ async def create_invite_message(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def copy_referral_link(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
await callback.answer(
|
||||
f"Ссылка скопирована: {referral_link}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -139,6 +245,18 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
copy_referral_link,
|
||||
F.data == "copy_referral_link"
|
||||
)
|
||||
show_detailed_referral_list,
|
||||
F.data == "referral_list"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_referral_analytics,
|
||||
F.data == "referral_analytics"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
lambda callback, db_user, db: show_detailed_referral_list(
|
||||
callback, db_user, db, int(callback.data.split('_')[-1])
|
||||
),
|
||||
F.data.startswith("referral_list_page_")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Ошибка валидации платежа. Попробуйте еще раз."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
if not user:
|
||||
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Пользователь не найден. Обратитесь в поддержку."
|
||||
)
|
||||
return
|
||||
break
|
||||
except Exception as db_error:
|
||||
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
)
|
||||
return
|
||||
|
||||
await query.answer(ok=True)
|
||||
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
)
|
||||
|
||||
|
||||
async def handle_successful_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
**kwargs
|
||||
):
|
||||
try:
|
||||
payment = message.successful_payment
|
||||
user_id = message.from_user.id
|
||||
|
||||
logger.info(
|
||||
f"💳 Успешный Stars платеж от {user_id}: "
|
||||
f"{payment.total_amount} XTR, "
|
||||
f"payload: {payment.invoice_payload}, "
|
||||
f"charge_id: {payment.telegram_payment_charge_id}"
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
|
||||
await message.answer(
|
||||
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
success = await payment_service.process_stars_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
stars_amount=payment.total_amount,
|
||||
payload=payment.invoice_payload,
|
||||
telegram_payment_charge_id=payment.telegram_payment_charge_id
|
||||
)
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {rubles_amount:.2f} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {rubles_amount:.2f}₽"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при зачислении средств. "
|
||||
"Обратитесь в поддержку, платеж будет проверен вручную."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
|
||||
await message.answer(
|
||||
"❌ Техническая ошибка при обработке платежа. "
|
||||
"Обратитесь в поддержку для решения проблемы."
|
||||
)
|
||||
|
||||
|
||||
def register_stars_handlers(dp: Dispatcher):
|
||||
|
||||
dp.pre_checkout_query.register(
|
||||
handle_pre_checkout_query,
|
||||
F.currency == "XTR"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
handle_successful_payment,
|
||||
F.successful_payment
|
||||
)
|
||||
|
||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -17,9 +18,66 @@ from app.localization.texts import get_texts
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_potential_referral_code(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
|
||||
|
||||
if current_state not in [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state,
|
||||
None
|
||||
]:
|
||||
return False
|
||||
|
||||
user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
if user and user.status == UserStatus.ACTIVE.value:
|
||||
return False
|
||||
|
||||
potential_code = message.text.strip()
|
||||
if len(potential_code) < 4 or len(potential_code) > 20:
|
||||
return False
|
||||
|
||||
referrer = await get_user_by_referral_code(db, potential_code)
|
||||
if not referrer:
|
||||
await message.answer(
|
||||
"❌ Неверный реферальный код.\n\n"
|
||||
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
|
||||
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start"
|
||||
)
|
||||
return True
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['referral_code'] = potential_code
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer("✅ Реферальный код принят!")
|
||||
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после ввода реферального кода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
|
||||
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
|
||||
|
||||
@@ -36,6 +94,37 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
|
||||
|
||||
profile_updated = False
|
||||
|
||||
if user.username != message.from_user.username:
|
||||
old_username = user.username
|
||||
user.username = message.from_user.username
|
||||
logger.info(f"📝 Username обновлен: '{old_username}' → '{user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if user.first_name != message.from_user.first_name:
|
||||
old_first_name = user.first_name
|
||||
user.first_name = message.from_user.first_name
|
||||
logger.info(f"📝 Имя обновлено: '{old_first_name}' → '{user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if user.last_name != message.from_user.last_name:
|
||||
old_last_name = user.last_name
|
||||
user.last_name = message.from_user.last_name
|
||||
logger.info(f"📝 Фамилия обновлена: '{old_last_name}' → '{user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
if profile_updated:
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен")
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
if referral_code and not user.referred_by_id:
|
||||
@@ -50,7 +139,6 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
balance=texts.format_price(user.balance_kopeks),
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -58,7 +146,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
@@ -107,6 +196,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.referred_by_id = None
|
||||
|
||||
user.username = message.from_user.username
|
||||
user.first_name = message.from_user.first_name
|
||||
user.last_name = message.from_user.last_name
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
|
||||
|
||||
@@ -285,12 +381,12 @@ async def process_referral_code_skip(
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
|
||||
|
||||
async def complete_registration_from_callback(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
@@ -299,6 +395,10 @@ async def complete_registration_from_callback(
|
||||
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
@@ -306,21 +406,24 @@ async def complete_registration_from_callback(
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
user_name = existing_user.full_name
|
||||
user_telegram_id = existing_user.telegram_id
|
||||
user_language = existing_user.language
|
||||
has_had_paid_subscription = existing_user.has_had_paid_subscription
|
||||
balance_kopeks = existing_user.balance_kopeks
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
balance=texts.format_price(balance_kopeks),
|
||||
subscription_status=_get_subscription_status(existing_user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -362,7 +465,7 @@ async def complete_registration_from_callback(
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
|
||||
|
||||
else:
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
|
||||
@@ -377,12 +480,25 @@ async def complete_registration_from_callback(
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {callback.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
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
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id)
|
||||
bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!"
|
||||
await callback.message.answer(bonus_message)
|
||||
await process_referral_registration(db, user.id, referrer_id, callback.bot)
|
||||
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
@@ -390,7 +506,6 @@ async def complete_registration_from_callback(
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
@@ -401,7 +516,6 @@ async def complete_registration_from_callback(
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
balance=texts.format_price(balance_kopeks),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -409,7 +523,8 @@ async def complete_registration_from_callback(
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
@@ -419,38 +534,79 @@ async def complete_registration_from_callback(
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await callback.message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles} ₽\n"
|
||||
f"Баланс: {balance_rubles:.2f} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await callback.message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as final_error:
|
||||
logger.error(f"❌ Критическая ошибка при отправке простого сообщения: {final_error}")
|
||||
logger.error(f"⛔ Критическая ошибка при отправке простого сообщения: {final_error}")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
|
||||
|
||||
|
||||
async def complete_registration(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
user_name = existing_user.full_name
|
||||
user_telegram_id = existing_user.telegram_id
|
||||
user_language = existing_user.language
|
||||
has_had_paid_subscription = existing_user.has_had_paid_subscription
|
||||
balance_kopeks = existing_user.balance_kopeks
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status(existing_user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await message.answer(f"Добро пожаловать, {user_name}!")
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
@@ -483,7 +639,7 @@ async def complete_registration(
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
|
||||
|
||||
else:
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, message.from_user.id)
|
||||
@@ -498,12 +654,25 @@ async def complete_registration(
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {message.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
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
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id)
|
||||
bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!"
|
||||
await message.answer(bonus_message)
|
||||
await process_referral_registration(db, user.id, referrer_id, message.bot)
|
||||
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
@@ -511,7 +680,6 @@ async def complete_registration(
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
@@ -522,7 +690,6 @@ async def complete_registration(
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
balance=texts.format_price(balance_kopeks),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -530,7 +697,8 @@ async def complete_registration(
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
@@ -540,19 +708,20 @@ async def complete_registration(
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles} ₽\n"
|
||||
f"Баланс: {balance_rubles:.2f} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
@@ -563,9 +732,44 @@ async def complete_registration(
|
||||
|
||||
|
||||
def _get_subscription_status(user, texts):
|
||||
if user.subscription and user.subscription.is_active:
|
||||
return getattr(texts, 'SUBSCRIPTION_ACTIVE', 'Активна')
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
if not user or not hasattr(user, 'subscription'):
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
if not user.subscription:
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
from datetime import datetime
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if hasattr(subscription, 'end_date') and subscription.end_date <= current_time:
|
||||
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
if hasattr(subscription, 'end_date'):
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
else:
|
||||
days_left = 0
|
||||
|
||||
is_trial = getattr(subscription, 'is_trial', False)
|
||||
|
||||
if is_trial:
|
||||
if days_left > 1:
|
||||
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left == 1:
|
||||
return "🎁 Тестовая подписка\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return "🎁 Тестовая подписка\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 "💎 Активна\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return "💎 Активна\n⚠️ истекает сегодня!"
|
||||
|
||||
|
||||
|
||||
def _get_subscription_status_simple(texts):
|
||||
@@ -614,4 +818,13 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_input")
|
||||
|
||||
dp.message.register(
|
||||
handle_potential_referral_code,
|
||||
StateFilter(
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован handle_potential_referral_code")
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
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
|
||||
@@ -25,7 +25,8 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics")
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"),
|
||||
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
@@ -75,7 +76,7 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_promo_stats")
|
||||
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_promo_general_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
@@ -179,7 +180,10 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
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_servers_{user_id}"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}")
|
||||
]
|
||||
]
|
||||
@@ -575,3 +579,33 @@ def get_admin_pagination_keyboard(
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup:
|
||||
|
||||
if language == "en":
|
||||
toggle_text = "🔴 Disable maintenance" if is_active else "🔧 Enable maintenance"
|
||||
monitoring_text = "⏹️ Stop monitoring" if monitoring_active else "🔄 Start monitoring"
|
||||
check_api_text = "🔍 Check API"
|
||||
back_text = "⬅️ Back to admin"
|
||||
else:
|
||||
toggle_text = "🔴 Выключить техработы" if is_active else "🔧 Включить техработы"
|
||||
monitoring_text = "⏹️ Остановить мониторинг" if monitoring_active else "🔄 Запустить мониторинг"
|
||||
check_api_text = "🔍 Проверить API"
|
||||
back_text = "⬅️ Назад в админку"
|
||||
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(text=toggle_text, callback_data="maintenance_toggle")],
|
||||
[InlineKeyboardButton(text=monitoring_text, callback_data="maintenance_monitoring")],
|
||||
[InlineKeyboardButton(text=check_api_text, callback_data="maintenance_check_api")],
|
||||
[InlineKeyboardButton(text=back_text, callback_data="admin_panel")]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from typing import List, Optional
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.texts import get_texts
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
@@ -20,20 +24,29 @@ def get_main_menu_keyboard(
|
||||
is_admin: bool = False,
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False
|
||||
subscription_is_active: bool = False,
|
||||
balance_kopeks: int = 0
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}")
|
||||
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}")
|
||||
|
||||
if hasattr(texts, 'BALANCE_BUTTON') and balance_kopeks > 0:
|
||||
balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(balance_kopeks))
|
||||
else:
|
||||
balance_button_text = f"💰 Баланс: {texts.format_price(balance_kopeks)}"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_BALANCE, callback_data="menu_balance"),
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
]
|
||||
]
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
@@ -107,15 +120,28 @@ def get_subscription_keyboard(
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
button_text = "🚀 Открыть в мини-приложении"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
button_text = "🚀 Открыть приложение"
|
||||
if settings.MINIAPP_CUSTOM_URL:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
else:
|
||||
button_text = "🔗 Подключиться"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=button_text, callback_data="subscription_connect")
|
||||
])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
|
||||
if not is_trial and subscription and subscription.days_left <= 3:
|
||||
keyboard.append([
|
||||
@@ -142,27 +168,31 @@ def get_subscription_keyboard(
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_subscription_settings_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
def get_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = [
|
||||
[
|
||||
keyboard = []
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries")
|
||||
],
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices")
|
||||
]
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(1, [
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
|
||||
])
|
||||
keyboard.insert(2, [
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
@@ -187,19 +217,25 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
periods = [
|
||||
(14, texts.PERIOD_14_DAYS),
|
||||
(30, texts.PERIOD_30_DAYS),
|
||||
(60, texts.PERIOD_60_DAYS),
|
||||
(90, texts.PERIOD_90_DAYS),
|
||||
(180, texts.PERIOD_180_DAYS),
|
||||
(360, texts.PERIOD_360_DAYS)
|
||||
]
|
||||
available_periods = settings.get_available_subscription_periods()
|
||||
|
||||
for days, text in periods:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"period_{days}")
|
||||
])
|
||||
period_texts = {
|
||||
14: texts.PERIOD_14_DAYS,
|
||||
30: texts.PERIOD_30_DAYS,
|
||||
60: texts.PERIOD_60_DAYS,
|
||||
90: texts.PERIOD_90_DAYS,
|
||||
180: texts.PERIOD_180_DAYS,
|
||||
360: texts.PERIOD_360_DAYS
|
||||
}
|
||||
|
||||
for days in available_periods:
|
||||
if days in period_texts:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=period_texts[days],
|
||||
callback_data=f"period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
@@ -209,28 +245,57 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
|
||||
|
||||
def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
return get_back_keyboard(language)
|
||||
|
||||
logger.info(f"🔍 RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'")
|
||||
|
||||
all_packages = settings.get_traffic_packages()
|
||||
logger.info(f"🔍 ALL PACKAGES: {all_packages}")
|
||||
|
||||
enabled_packages = [pkg for pkg in all_packages if pkg['enabled']]
|
||||
disabled_packages = [pkg for pkg in all_packages if not pkg['enabled']]
|
||||
|
||||
logger.info(f"🔍 ENABLED: {len(enabled_packages)} packages")
|
||||
logger.info(f"🔍 DISABLED: {len(disabled_packages)} packages")
|
||||
|
||||
for pkg in disabled_packages:
|
||||
logger.info(f"🔍 DISABLED PACKAGE: {pkg['gb']}GB = {pkg['price']} kopeks, enabled={pkg['enabled']}")
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
packages = [
|
||||
(5, texts.TRAFFIC_5GB),
|
||||
(10, texts.TRAFFIC_10GB),
|
||||
(25, texts.TRAFFIC_25GB),
|
||||
(50, texts.TRAFFIC_50GB),
|
||||
(100, texts.TRAFFIC_100GB),
|
||||
(250, texts.TRAFFIC_250GB),
|
||||
(0, texts.TRAFFIC_UNLIMITED)
|
||||
]
|
||||
traffic_packages = settings.get_traffic_packages()
|
||||
|
||||
for gb, text in packages:
|
||||
for package in traffic_packages:
|
||||
gb = package["gb"]
|
||||
price = package["price"]
|
||||
enabled = package["enabled"]
|
||||
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
if gb == 0:
|
||||
text = f"♾️ Безлимит - {settings.format_price(package['price'])}"
|
||||
else:
|
||||
text = f"📊 {gb} ГБ - {settings.format_price(package['price'])}"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"traffic_{gb}")
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Пакеты трафика не настроены",
|
||||
callback_data="no_traffic_packages"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")
|
||||
@@ -238,7 +303,6 @@ def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
@@ -281,17 +345,31 @@ def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMa
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
for devices in range(1, 6):
|
||||
price = (devices - 1) * settings.PRICE_PER_DEVICE
|
||||
price_text = f" (+{texts.format_price(price)})" if price > 0 else ""
|
||||
start_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 50
|
||||
end_devices = min(max_devices + 1, start_devices + 10)
|
||||
|
||||
buttons = []
|
||||
|
||||
for devices in range(start_devices, end_devices):
|
||||
price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
price_text = f" (+{texts.format_price(price)})" if price > 0 else " (вкл.)"
|
||||
emoji = "✅" if devices == current else "⚪"
|
||||
|
||||
keyboard.append([
|
||||
button_text = f"{emoji} {devices}{price_text}"
|
||||
|
||||
buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji} {devices} устройство{_get_device_suffix(devices)}{price_text}",
|
||||
text=button_text,
|
||||
callback_data=f"devices_{devices}"
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
for i in range(0, len(buttons), 2):
|
||||
if i + 1 < len(buttons):
|
||||
keyboard.append([buttons[i], buttons[i + 1]])
|
||||
else:
|
||||
keyboard.append([buttons[i]])
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="✅ Продолжить", callback_data="devices_continue")],
|
||||
@@ -300,6 +378,13 @@ def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMa
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def _get_device_declension(count: int) -> str:
|
||||
if count % 10 == 1 and count % 100 != 11:
|
||||
return "устройство"
|
||||
elif count % 10 in [2, 3, 4] and count % 100 not in [12, 13, 14]:
|
||||
return "устройства"
|
||||
else:
|
||||
return "устройств"
|
||||
|
||||
def get_subscription_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
@@ -339,10 +424,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="💳 Банковская карта (YooKassa)",
|
||||
callback_data="topup_yookassa"
|
||||
)
|
||||
])
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="💳 Банковская карта",
|
||||
text="💎 Банковская карта (Tribute)",
|
||||
callback_data="topup_tribute"
|
||||
)
|
||||
])
|
||||
@@ -360,6 +453,34 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_yookassa_payment_keyboard(
|
||||
payment_id: str,
|
||||
amount_kopeks: int,
|
||||
confirmation_url: str,
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💳 Оплатить",
|
||||
url=confirmation_url
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Проверить статус",
|
||||
callback_data=f"check_yookassa_status_{payment_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
def get_autopay_notification_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -404,14 +525,35 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru
|
||||
|
||||
def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=texts.CREATE_INVITE, callback_data="referral_create_invite")
|
||||
InlineKeyboardButton(
|
||||
text="📝 Создать приглашение",
|
||||
callback_data="referral_create_invite"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
InlineKeyboardButton(
|
||||
text="👥 Список рефералов",
|
||||
callback_data="referral_list"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Аналитика",
|
||||
callback_data="referral_analytics"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="back_to_menu"
|
||||
)
|
||||
]
|
||||
])
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_support_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
@@ -458,16 +600,6 @@ def get_pagination_keyboard(
|
||||
|
||||
return keyboard
|
||||
|
||||
|
||||
def _get_device_suffix(count: int) -> str:
|
||||
if count == 1:
|
||||
return ""
|
||||
elif 2 <= count <= 4:
|
||||
return "а"
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def get_confirmation_keyboard(
|
||||
confirm_data: str,
|
||||
cancel_data: str = "cancel",
|
||||
@@ -548,58 +680,103 @@ def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
return get_back_keyboard(language)
|
||||
months_multiplier = 1
|
||||
period_text = ""
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f" (за {months_multiplier} мес)"
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
packages = settings.get_traffic_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
|
||||
packages = [
|
||||
(5, f"📊 +5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"),
|
||||
(10, f"📊 +10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"),
|
||||
(25, f"📊 +25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"),
|
||||
(50, f"📊 +50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"),
|
||||
(100, f"📊 +100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"),
|
||||
(0, f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}")
|
||||
]
|
||||
if not enabled_packages:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="❌ Нет доступных пакетов" if language == "ru" else "❌ No packages available",
|
||||
callback_data="no_traffic_packages"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
)]
|
||||
])
|
||||
|
||||
for gb, text in packages:
|
||||
keyboard.append([
|
||||
buttons = []
|
||||
|
||||
for package in enabled_packages:
|
||||
gb = package['gb']
|
||||
price_per_month = package['price']
|
||||
total_price = price_per_month * months_multiplier
|
||||
|
||||
if gb == 0:
|
||||
if language == "ru":
|
||||
text = f"♾️ Безлимитный трафик - {total_price/100:.2f} ₽{period_text}"
|
||||
else:
|
||||
text = f"♾️ Unlimited traffic - {total_price/100:.2f} ₽{period_text}"
|
||||
else:
|
||||
if language == "ru":
|
||||
text = f"📊 +{gb} ГБ трафика - {total_price/100:.2f} ₽{period_text}"
|
||||
else:
|
||||
text = f"📊 +{gb} GB traffic - {total_price/100:.2f} ₽{period_text}"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
max_devices = 10
|
||||
def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
for add_count in range(1, min(6, max_devices - current_devices + 1)):
|
||||
price = add_count * settings.PRICE_PER_DEVICE
|
||||
total_devices = current_devices + add_count
|
||||
months_multiplier = 1
|
||||
period_text = ""
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f" (за {months_multiplier} мес)"
|
||||
|
||||
device_price_per_month = settings.PRICE_PER_DEVICE
|
||||
|
||||
buttons = []
|
||||
|
||||
for count in [1, 2, 3, 4, 5]:
|
||||
new_total = current_devices + count
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and new_total > settings.MAX_DEVICES_LIMIT:
|
||||
continue
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📱 +{add_count} устройство{_get_device_suffix(add_count)} (итого: {total_devices}) - {settings.format_price(price)}",
|
||||
callback_data=f"add_devices_{add_count}"
|
||||
)
|
||||
price_per_month = count * device_price_per_month
|
||||
total_price = price_per_month * months_multiplier
|
||||
|
||||
if language == "ru":
|
||||
text = f"📱 +{count} устройство(а) (итого: {new_total}) - {total_price/100:.2f} ₽{period_text}"
|
||||
else:
|
||||
text = f"📱 +{count} device(s) (total: {new_total}) - {total_price/100:.2f} ₽{period_text}"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_devices_{count}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
@@ -625,64 +802,72 @@ def get_manage_countries_keyboard(
|
||||
countries: List[dict],
|
||||
selected: List[str],
|
||||
current_subscription_countries: List[str],
|
||||
language: str = "ru"
|
||||
language: str = "ru",
|
||||
subscription_end_date: datetime = None
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
months_multiplier = 1
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
logger.info(f"🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}")
|
||||
|
||||
buttons = []
|
||||
total_cost = 0
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
uuid = country['uuid']
|
||||
name = country['name']
|
||||
price_per_month = country['price_kopeks']
|
||||
|
||||
is_currently_connected = country['uuid'] in current_subscription_countries
|
||||
is_selected = country['uuid'] in selected
|
||||
|
||||
if is_currently_connected:
|
||||
if is_selected:
|
||||
emoji = "✅"
|
||||
status = ""
|
||||
if uuid in current_subscription_countries:
|
||||
if uuid in selected:
|
||||
icon = "✅"
|
||||
else:
|
||||
emoji = "➖"
|
||||
status = " (отключить БЕСПЛАТНО)"
|
||||
icon = "➖"
|
||||
else:
|
||||
if is_selected:
|
||||
emoji = "➕"
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
|
||||
status = price_text
|
||||
if uuid in selected:
|
||||
icon = "➕"
|
||||
total_cost += price_per_month * months_multiplier
|
||||
else:
|
||||
emoji = "⚪"
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
|
||||
status = price_text
|
||||
icon = "⚪"
|
||||
|
||||
keyboard.append([
|
||||
if uuid not in current_subscription_countries and uuid in selected:
|
||||
total_price = price_per_month * months_multiplier
|
||||
if months_multiplier > 1:
|
||||
price_text = f" ({price_per_month/100:.2f}₽/мес × {months_multiplier} = {total_price/100:.2f}₽)"
|
||||
logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽")
|
||||
else:
|
||||
price_text = f" ({total_price/100:.2f}₽)"
|
||||
display_name = f"{icon} {name}{price_text}"
|
||||
else:
|
||||
display_name = f"{icon} {name}"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji} {country['name']}{status}",
|
||||
callback_data=f"country_manage_{country['uuid']}"
|
||||
text=display_name,
|
||||
callback_data=f"country_manage_{uuid}"
|
||||
)
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Нет доступных серверов",
|
||||
callback_data="no_servers"
|
||||
)
|
||||
])
|
||||
if total_cost > 0:
|
||||
apply_text = f"✅ Применить изменения ({total_cost/100:.2f} ₽)"
|
||||
logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}₽")
|
||||
else:
|
||||
apply_text = "✅ Применить изменения"
|
||||
|
||||
added = [c for c in selected if c not in current_subscription_countries]
|
||||
removed = [c for c in current_subscription_countries if c not in selected]
|
||||
|
||||
apply_text = "✅ Применить изменения"
|
||||
if added or removed:
|
||||
changes_count = len(added) + len(removed)
|
||||
apply_text += f" ({changes_count})"
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text=apply_text, callback_data="countries_apply")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")]
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=apply_text, callback_data="countries_apply")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_device_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
@@ -833,29 +1018,31 @@ def get_specific_app_keyboard(
|
||||
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)
|
||||
keyboard = []
|
||||
|
||||
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")
|
||||
]
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
period_display = {
|
||||
14: "14 дней",
|
||||
30: "30 дней",
|
||||
60: "60 дней",
|
||||
90: "90 дней",
|
||||
180: "180 дней",
|
||||
360: "360 дней"
|
||||
}
|
||||
|
||||
for days in available_periods:
|
||||
if days in prices and days in period_display:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display[days]} - {texts.format_price(prices[days])}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -105,19 +105,17 @@ class RussianTexts(Texts):
|
||||
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
|
||||
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
|
||||
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
|
||||
|
||||
|
||||
MAIN_MENU = """👤 <b>{user_name}</b>
|
||||
━━━━━━━━━━━━━━━━━
|
||||
💰 <b>Баланс:</b> {balance}
|
||||
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
━━━━━━━━━━━━━━━━━
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
MENU_BALANCE = "💰 Баланс"
|
||||
MENU_SUBSCRIPTION = "📱 Подписка"
|
||||
MENU_TRIAL = "🎁 Тестовая подписка"
|
||||
MENU_TRIAL = "🧪 Тестовая подписка"
|
||||
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
|
||||
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
|
||||
MENU_PROMOCODE = "🎫 Промокод"
|
||||
@@ -126,9 +124,11 @@ class RussianTexts(Texts):
|
||||
MENU_RULES = "📋 Правила сервиса"
|
||||
MENU_LANGUAGE = "🌐 Язык"
|
||||
MENU_ADMIN = "⚙️ Админ-панель"
|
||||
BALANCE_BUTTON = "💰 Баланс: {balance}"
|
||||
BALANCE_BUTTON_ZERO = "💰 Баланс: 0.00 ₽"
|
||||
|
||||
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
|
||||
SUBSCRIPTION_TRIAL = "🎁 Тестовая подписка"
|
||||
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
|
||||
SUBSCRIPTION_ACTIVE = "✅ Активна"
|
||||
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
|
||||
|
||||
@@ -280,10 +280,31 @@ class RussianTexts(Texts):
|
||||
• Поддержка до 3 устройств
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_ACTIVE = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
|
||||
|
||||
⏰ Ориентировочное время завершения: неизвестно
|
||||
🔄 Попробуйте позже
|
||||
|
||||
Приносим извинения за временные неудобства.
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_API_ERROR = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен из-за проблем с подключением к серверам.
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {last_check}
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRING_PAID = """
|
||||
⚠️ <b>Подписка истекает через {days} дней!</b>
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {end_date}.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser
|
||||
@@ -56,7 +57,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
|
||||
@@ -124,8 +125,33 @@ class AuthMiddleware(BaseMiddleware):
|
||||
logger.info(f"❌ Удаленный пользователь {user.id} попытался использовать бота без /start")
|
||||
return
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
profile_updated = False
|
||||
|
||||
if db_user.username != user.username:
|
||||
old_username = db_user.username
|
||||
db_user.username = user.username
|
||||
logger.info(f"📝 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.first_name != user.first_name:
|
||||
old_first_name = db_user.first_name
|
||||
db_user.first_name = user.first_name
|
||||
logger.info(f"📝 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.last_name != user.last_name:
|
||||
old_last_name = db_user.last_name
|
||||
db_user.last_name = user.last_name
|
||||
logger.info(f"📝 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
|
||||
if profile_updated:
|
||||
db_user.updated_at = datetime.utcnow()
|
||||
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
|
||||
|
||||
await db.commit()
|
||||
|
||||
data['db'] = db
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser
|
||||
|
||||
from app.config import settings
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaintenanceMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
user: TgUser = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user or user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
if not maintenance_service.is_maintenance_active():
|
||||
return await handler(event, data)
|
||||
|
||||
if settings.is_admin(user.id):
|
||||
return await handler(event, data)
|
||||
|
||||
maintenance_message = maintenance_service.get_maintenance_message()
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(maintenance_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(maintenance_message, show_alert=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки сообщения о техработах пользователю {user.id}: {e}")
|
||||
|
||||
logger.info(f"🔧 Пользователь {user.id} заблокирован во время техработ")
|
||||
return
|
||||
@@ -0,0 +1,277 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, Subscription, Transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminNotificationService:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
async def send_trial_activation_notification(
|
||||
self,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {f'ID {user.referred_by_id}' if user.referred_by_id else 'Нет'}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о триале: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_purchase_notification(
|
||||
self,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
|
||||
|
||||
if was_trial_conversion:
|
||||
user_status = "🎯 Конверсия из триала"
|
||||
elif user.has_had_paid_subscription:
|
||||
user_status = "🔄 Продление/Обновление"
|
||||
else:
|
||||
user_status = "🆕 Первая покупка"
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
|
||||
📱 <b>Параметры подписки:</b>
|
||||
📅 Период: {period_days} дней
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {f'ID {user.referred_by_id}' if user.referred_by_id else 'Нет'}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
|
||||
return False
|
||||
|
||||
async def send_balance_topup_notification(
|
||||
self,
|
||||
user: User,
|
||||
transaction: Transaction,
|
||||
old_balance: int
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение"
|
||||
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
|
||||
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
💰 <b>Детали пополнения:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
|
||||
💰 <b>Баланс:</b>
|
||||
📉 Было: {settings.format_price(old_balance)}
|
||||
📈 Стало: {settings.format_price(user.balance_kopeks)}
|
||||
➕ Изменение: +{settings.format_price(balance_change)}
|
||||
|
||||
🔗 <b>Реферер:</b> {f'ID {user.referred_by_id}' if user.referred_by_id else 'Нет'}
|
||||
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_extension_notification(
|
||||
self,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
extended_days: int,
|
||||
old_end_date: datetime
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
|
||||
return False
|
||||
|
||||
try:
|
||||
message_kwargs = {
|
||||
'chat_id': self.chat_id,
|
||||
'text': text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
|
||||
if self.topic_id:
|
||||
message_kwargs['message_thread_id'] = self.topic_id
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info(f"Уведомление отправлено в чат {self.chat_id}")
|
||||
return True
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.error(f"Бот не имеет прав для отправки в чат {self.chat_id}")
|
||||
return False
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f"Ошибка отправки уведомления: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке уведомления: {e}")
|
||||
return False
|
||||
|
||||
def _is_enabled(self) -> bool:
|
||||
return self.enabled and bool(self.chat_id)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: Optional[str]) -> str:
|
||||
method_names = {
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
}
|
||||
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
|
||||
return method_names.get(payment_method, f'💰 С баланса')
|
||||
|
||||
def _format_traffic(self, traffic_gb: int) -> str:
|
||||
if traffic_gb == 0:
|
||||
return "∞ Безлимит"
|
||||
return f"{traffic_gb} ГБ"
|
||||
|
||||
def _get_subscription_status(self, user: User) -> str:
|
||||
if not user.subscription:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
sub = user.subscription
|
||||
if sub.is_trial:
|
||||
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
|
||||
elif sub.is_active:
|
||||
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
async def _get_servers_info(self, squad_uuids: list) -> str:
|
||||
if not squad_uuids:
|
||||
return "❌ Нет серверов"
|
||||
|
||||
try:
|
||||
from app.handlers.subscription import get_servers_display_names
|
||||
servers_names = await get_servers_display_names(squad_uuids)
|
||||
return f"{len(squad_uuids)} шт. ({servers_names})"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось получить названия серверов: {e}")
|
||||
return f"{len(squad_uuids)} шт."
|
||||
@@ -0,0 +1,406 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
|
||||
from app.utils.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaintenanceStatus:
|
||||
is_active: bool
|
||||
enabled_at: Optional[datetime] = None
|
||||
last_check: Optional[datetime] = None
|
||||
reason: Optional[str] = None
|
||||
auto_enabled: bool = False
|
||||
api_status: bool = True
|
||||
consecutive_failures: int = 0
|
||||
|
||||
|
||||
class MaintenanceService:
|
||||
|
||||
def __init__(self):
|
||||
self._status = MaintenanceStatus(is_active=False)
|
||||
self._check_task: Optional[asyncio.Task] = None
|
||||
self._is_checking = False
|
||||
self._max_consecutive_failures = 3
|
||||
self._bot = None
|
||||
self._last_notification_sent = None
|
||||
|
||||
def set_bot(self, bot):
|
||||
"""Устанавливает ссылку на бота для отправки уведомлений"""
|
||||
self._bot = bot
|
||||
logger.info("Бот установлен для maintenance_service")
|
||||
|
||||
@property
|
||||
def status(self) -> MaintenanceStatus:
|
||||
return self._status
|
||||
|
||||
def is_maintenance_active(self) -> bool:
|
||||
return self._status.is_active
|
||||
|
||||
def get_maintenance_message(self) -> str:
|
||||
if self._status.auto_enabled:
|
||||
return f"""
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен из-за проблем с подключением к серверам.
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
|
||||
"""
|
||||
else:
|
||||
return settings.get_maintenance_message()
|
||||
|
||||
async def _notify_admins(self, message: str, alert_type: str = "info"):
|
||||
"""Отправка уведомлений администраторам"""
|
||||
if not self._bot:
|
||||
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
|
||||
return
|
||||
|
||||
cache_key = f"maintenance_notification_{alert_type}"
|
||||
if await cache.get(cache_key):
|
||||
return
|
||||
|
||||
admin_ids = settings.get_admin_ids()
|
||||
if not admin_ids:
|
||||
logger.warning("Список администраторов пуст")
|
||||
return
|
||||
|
||||
emoji_map = {
|
||||
"error": "🚨",
|
||||
"warning": "⚠️",
|
||||
"success": "✅",
|
||||
"info": "ℹ️"
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
formatted_message = f"{emoji} <b>Maintenance Service</b>\n\n{message}"
|
||||
|
||||
success_count = 0
|
||||
for admin_id in admin_ids:
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=admin_id,
|
||||
text=formatted_message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
success_count += 1
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления админу {admin_id}: {e}")
|
||||
|
||||
if success_count > 0:
|
||||
logger.info(f"Уведомление отправлено {success_count} администраторам")
|
||||
await cache.set(cache_key, True, expire=300)
|
||||
else:
|
||||
logger.error("Не удалось отправить уведомления ни одному администратору")
|
||||
|
||||
async def enable_maintenance(self, reason: Optional[str] = None, auto: bool = False) -> bool:
|
||||
try:
|
||||
if self._status.is_active:
|
||||
logger.warning("Режим техработ уже включен")
|
||||
return True
|
||||
|
||||
self._status.is_active = True
|
||||
self._status.enabled_at = datetime.utcnow()
|
||||
self._status.reason = reason or ("Автоматическое включение" if auto else "Включено администратором")
|
||||
self._status.auto_enabled = auto
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
notification_msg = f"""
|
||||
Режим технических работ ВКЛЮЧЕН
|
||||
|
||||
📋 <b>Причина:</b> {self._status.reason}
|
||||
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
|
||||
|
||||
Обычные пользователи временно не смогут использовать бота.
|
||||
"""
|
||||
|
||||
await self._notify_admins(notification_msg, "warning" if auto else "info")
|
||||
|
||||
logger.warning(f"🔧 Режим техработ ВКЛЮЧЕН. Причина: {self._status.reason}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка включения режима техработ: {e}")
|
||||
return False
|
||||
|
||||
async def disable_maintenance(self) -> bool:
|
||||
try:
|
||||
if not self._status.is_active:
|
||||
logger.info("Режим техработ уже выключен")
|
||||
return True
|
||||
|
||||
was_auto = self._status.auto_enabled
|
||||
duration = None
|
||||
if self._status.enabled_at:
|
||||
duration = datetime.utcnow() - self._status.enabled_at
|
||||
|
||||
self._status.is_active = False
|
||||
self._status.enabled_at = None
|
||||
self._status.reason = None
|
||||
self._status.auto_enabled = False
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
duration_str = ""
|
||||
if duration:
|
||||
hours = int(duration.total_seconds() // 3600)
|
||||
minutes = int((duration.total_seconds() % 3600) // 60)
|
||||
if hours > 0:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {hours}ч {minutes}мин"
|
||||
else:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
|
||||
|
||||
notification_msg = f"""
|
||||
Режим технических работ ВЫКЛЮЧЕН
|
||||
|
||||
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
{duration_str}
|
||||
|
||||
Сервис снова доступен для пользователей.
|
||||
"""
|
||||
|
||||
await self._notify_admins(notification_msg, "success")
|
||||
|
||||
logger.info("✅ Режим техработ ВЫКЛЮЧЕН")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка выключения режима техработ: {e}")
|
||||
return False
|
||||
|
||||
async def start_monitoring(self) -> bool:
|
||||
try:
|
||||
if self._check_task and not self._check_task.done():
|
||||
logger.warning("Мониторинг уже запущен")
|
||||
return True
|
||||
|
||||
await self._load_status_from_cache()
|
||||
|
||||
self._check_task = asyncio.create_task(self._monitoring_loop())
|
||||
logger.info(f"🔄 Запущен мониторинг API RemnaWave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
|
||||
await self._notify_admins(f"""
|
||||
Мониторинг технических работ запущен
|
||||
|
||||
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
|
||||
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
|
||||
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
|
||||
|
||||
Система будет следить за доступностью API.
|
||||
""", "info")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга: {e}")
|
||||
return False
|
||||
|
||||
async def stop_monitoring(self) -> bool:
|
||||
try:
|
||||
if self._check_task and not self._check_task.done():
|
||||
self._check_task.cancel()
|
||||
try:
|
||||
await self._check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self._notify_admins("Мониторинг технических работ остановлен", "info")
|
||||
logger.info("ℹ️ Мониторинг API остановлен")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки мониторинга: {e}")
|
||||
return False
|
||||
|
||||
async def check_api_status(self) -> bool:
|
||||
try:
|
||||
if self._is_checking:
|
||||
return self._status.api_status
|
||||
|
||||
self._is_checking = True
|
||||
self._status.last_check = datetime.utcnow()
|
||||
|
||||
api = RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY)
|
||||
|
||||
async with api:
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
# API восстановилось
|
||||
if not self._status.api_status:
|
||||
await self._notify_admins(f"""
|
||||
API RemnaWave восстановлено!
|
||||
|
||||
✅ <b>Статус:</b> Доступно
|
||||
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
|
||||
|
||||
API снова отвечает на запросы.
|
||||
""", "success")
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
if self._status.is_active and self._status.auto_enabled:
|
||||
await self.disable_maintenance()
|
||||
logger.info("✅ API восстановился, режим техработ автоматически отключен")
|
||||
|
||||
return True
|
||||
else:
|
||||
was_available = self._status.api_status
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
await self._notify_admins(f"""
|
||||
API RemnaWave недоступно!
|
||||
|
||||
❌ <b>Статус:</b> Недоступно
|
||||
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
|
||||
|
||||
Началась серия неудачных проверок API.
|
||||
""", "error")
|
||||
|
||||
if (self._status.consecutive_failures >= self._max_consecutive_failures and
|
||||
not self._status.is_active and
|
||||
settings.is_maintenance_auto_enable()):
|
||||
|
||||
await self.enable_maintenance(
|
||||
reason=f"Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API",
|
||||
auto=True
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки API: {e}")
|
||||
|
||||
if self._status.api_status:
|
||||
await self._notify_admins(f"""
|
||||
Ошибка при проверке API RemnaWave
|
||||
|
||||
❌ <b>Ошибка:</b> {str(e)}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
|
||||
|
||||
Не удалось выполнить проверку доступности API.
|
||||
""", "error")
|
||||
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
return False
|
||||
finally:
|
||||
self._is_checking = False
|
||||
await self._save_status_to_cache()
|
||||
|
||||
async def _monitoring_loop(self):
|
||||
while True:
|
||||
try:
|
||||
await self.check_api_status()
|
||||
await asyncio.sleep(settings.get_maintenance_check_interval())
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Мониторинг отменен")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле мониторинга: {e}")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
async def _save_status_to_cache(self):
|
||||
try:
|
||||
status_data = {
|
||||
"is_active": self._status.is_active,
|
||||
"enabled_at": self._status.enabled_at.isoformat() if self._status.enabled_at else None,
|
||||
"reason": self._status.reason,
|
||||
"auto_enabled": self._status.auto_enabled,
|
||||
"consecutive_failures": self._status.consecutive_failures,
|
||||
"last_check": self._status.last_check.isoformat() if self._status.last_check else None
|
||||
}
|
||||
|
||||
await cache.set("maintenance_status", status_data, expire=3600)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения состояния в кеш: {e}")
|
||||
|
||||
async def _load_status_from_cache(self):
|
||||
try:
|
||||
status_data = await cache.get("maintenance_status")
|
||||
if not status_data:
|
||||
return
|
||||
|
||||
self._status.is_active = status_data.get("is_active", False)
|
||||
self._status.reason = status_data.get("reason")
|
||||
self._status.auto_enabled = status_data.get("auto_enabled", False)
|
||||
self._status.consecutive_failures = status_data.get("consecutive_failures", 0)
|
||||
|
||||
if status_data.get("enabled_at"):
|
||||
self._status.enabled_at = datetime.fromisoformat(status_data["enabled_at"])
|
||||
|
||||
if status_data.get("last_check"):
|
||||
self._status.last_check = datetime.fromisoformat(status_data["last_check"])
|
||||
|
||||
logger.info(f"🔥 Состояние техработ загружено из кеша: активен={self._status.is_active}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки состояния из кеша: {e}")
|
||||
|
||||
def get_status_info(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"is_active": self._status.is_active,
|
||||
"enabled_at": self._status.enabled_at,
|
||||
"last_check": self._status.last_check,
|
||||
"reason": self._status.reason,
|
||||
"auto_enabled": self._status.auto_enabled,
|
||||
"api_status": self._status.api_status,
|
||||
"consecutive_failures": self._status.consecutive_failures,
|
||||
"monitoring_active": self._check_task is not None and not self._check_task.done(),
|
||||
"auto_enable_configured": settings.is_maintenance_auto_enable(),
|
||||
"check_interval": settings.get_maintenance_check_interval(),
|
||||
"bot_connected": self._bot is not None
|
||||
}
|
||||
|
||||
async def force_api_check(self) -> Dict[str, Any]:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
api_status = await self.check_api_status()
|
||||
end_time = datetime.utcnow()
|
||||
response_time = (end_time - start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"api_available": api_status,
|
||||
"response_time": round(response_time, 2),
|
||||
"checked_at": end_time,
|
||||
"consecutive_failures": self._status.consecutive_failures
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
end_time = datetime.utcnow()
|
||||
response_time = (end_time - start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"api_available": False,
|
||||
"error": str(e),
|
||||
"response_time": round(response_time, 2),
|
||||
"checked_at": end_time,
|
||||
"consecutive_failures": self._status.consecutive_failures
|
||||
}
|
||||
|
||||
|
||||
maintenance_service = MaintenanceService()
|
||||
@@ -37,6 +37,7 @@ class MonitoringService:
|
||||
self.payment_service = PaymentService()
|
||||
self.bot = bot
|
||||
self._notified_users: Set[str] = set()
|
||||
self._last_cleanup = datetime.utcnow()
|
||||
|
||||
async def start_monitoring(self):
|
||||
if self.is_running:
|
||||
@@ -62,6 +63,8 @@ class MonitoringService:
|
||||
async def _monitoring_cycle(self):
|
||||
async for db in get_db():
|
||||
try:
|
||||
await self._cleanup_notification_cache()
|
||||
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
@@ -69,10 +72,6 @@ class MonitoringService:
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
|
||||
current_hour = datetime.utcnow().hour
|
||||
if current_hour == 0:
|
||||
self._notified_users.clear()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "monitoring_cycle_completed",
|
||||
"Цикл мониторинга успешно завершен",
|
||||
@@ -90,6 +89,15 @@ class MonitoringService:
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _cleanup_notification_cache(self):
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if (current_time - self._last_cleanup).total_seconds() >= 3600:
|
||||
old_count = len(self._notified_users)
|
||||
self._notified_users.clear()
|
||||
self._last_cleanup = current_time
|
||||
logger.info(f"🧹 Очищен кеш уведомлений ({old_count} записей)")
|
||||
|
||||
async def _check_expired_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
@@ -168,30 +176,52 @@ class MonitoringService:
|
||||
async def _check_expiring_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
warning_days = settings.get_autopay_warning_days()
|
||||
all_processed_users = set()
|
||||
|
||||
for days in warning_days:
|
||||
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
|
||||
sent_count = 0
|
||||
|
||||
for subscription in expiring_subscriptions:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"expiring_{user.telegram_id}_{days}d"
|
||||
if notification_key in self._notified_users:
|
||||
continue
|
||||
notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
|
||||
user_key = f"user_{user.telegram_id}_today"
|
||||
|
||||
if (notification_key in self._notified_users or
|
||||
user_key in all_processed_users):
|
||||
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
|
||||
continue
|
||||
|
||||
should_send = True
|
||||
for other_days in warning_days:
|
||||
if other_days < days:
|
||||
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
|
||||
if any(s.user_id == user.id for s in other_subs):
|
||||
should_send = False
|
||||
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
|
||||
break
|
||||
|
||||
if not should_send:
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
self._notified_users.add(notification_key)
|
||||
|
||||
logger.info(f"⚠️ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
success = await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
all_processed_users.add(user_key)
|
||||
sent_count += 1
|
||||
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
else:
|
||||
logger.warning(f"❌ Не удалось отправить уведомление пользователю {user.telegram_id}")
|
||||
|
||||
if expiring_subscriptions:
|
||||
if sent_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
db, "expiring_notifications_sent",
|
||||
f"Отправлено {len(expiring_subscriptions)} уведомлений об истечении через {days} дней",
|
||||
{"days": days, "count": len(expiring_subscriptions)}
|
||||
f"Отправлено {sent_count} уведомлений об истечении через {days} дней",
|
||||
{"days": days, "count": sent_count}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -220,15 +250,15 @@ class MonitoringService:
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"trial_2h_{user.telegram_id}"
|
||||
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
|
||||
if notification_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
await self._send_trial_ending_notification(user, subscription)
|
||||
self._notified_users.add(notification_key)
|
||||
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
success = await self._send_trial_ending_notification(user, subscription)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
|
||||
if trial_expiring:
|
||||
await self._log_monitoring_event(
|
||||
@@ -257,9 +287,9 @@ class MonitoringService:
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
|
||||
logger.info(f"📅 Текущее время: {current_time}")
|
||||
logger.info(f"📅 Пороговая дата: {threshold_date}")
|
||||
logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
|
||||
logger.debug(f"📅 Текущее время: {current_time}")
|
||||
logger.debug(f"📅 Пороговая дата: {threshold_date}")
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
|
||||
@@ -340,15 +370,15 @@ class MonitoringService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки автоплатежей: {e}")
|
||||
|
||||
async def _send_subscription_expired_notification(self, user: User):
|
||||
async def _send_subscription_expired_notification(self, user: User) -> bool:
|
||||
try:
|
||||
message = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
⛔ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
@@ -363,13 +393,18 @@ class MonitoringService:
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
|
||||
try:
|
||||
from app.utils.formatters import format_days_declension
|
||||
|
||||
texts = get_texts(user.language)
|
||||
days_text = format_days_declension(days, user.language)
|
||||
|
||||
if subscription.autopay_enabled:
|
||||
autopay_status = "✅ Включен - подписка продлится автоматически"
|
||||
@@ -379,14 +414,14 @@ class MonitoringService:
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days} дней!</b>
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
@@ -402,30 +437,32 @@ class MonitoringService:
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_trial_ending_notification(self, user: User, subscription: Subscription):
|
||||
async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
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 устройств
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
@@ -440,9 +477,11 @@ class MonitoringService:
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
|
||||
try:
|
||||
@@ -551,7 +590,7 @@ class MonitoringService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка логирования события мониторинга: {e}")
|
||||
|
||||
|
||||
async def get_monitoring_status(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Optional
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,6 +22,8 @@ class PaymentService:
|
||||
|
||||
def __init__(self, bot: Optional[Bot] = None):
|
||||
self.bot = bot
|
||||
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
|
||||
self.stars_service = TelegramStarsService(bot) if bot else None
|
||||
|
||||
async def create_stars_invoice(
|
||||
self,
|
||||
@@ -22,28 +32,369 @@ class PaymentService:
|
||||
payload: Optional[str] = None
|
||||
) -> str:
|
||||
|
||||
if not self.bot:
|
||||
if not self.bot or not self.stars_service:
|
||||
raise ValueError("Bot instance required for Stars payments")
|
||||
|
||||
try:
|
||||
stars_amount = max(1, amount_kopeks // 100)
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title="Пополнение баланса VPN",
|
||||
description=description,
|
||||
description=f"{description} (≈{stars_amount} ⭐)",
|
||||
payload=payload or f"balance_topup_{amount_kopeks}",
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд")
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽)")
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
raise
|
||||
|
||||
async def process_stars_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
stars_amount: int,
|
||||
payload: str,
|
||||
telegram_payment_charge_id: str
|
||||
) -> bool:
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
amount_kopeks = int(rubles_amount * 100)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=telegram_payment_charge_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
|
||||
description_for_referral = f"Пополнение Stars: {rubles_amount:.2f}₽ ({stars_amount} ⭐)"
|
||||
logger.info(f"🔍 Проверка реферальной логики для описания: '{description_for_referral}'")
|
||||
|
||||
if any(word in description_for_referral.lower() for word in ["пополнение", "stars", "yookassa", "topup"]) and not any(word in description_for_referral.lower() for word in ["комиссия", "бонус"]):
|
||||
logger.info(f"🔞 Вызов process_referral_topup для пользователя {user_id}")
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user_id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
else:
|
||||
logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {rubles_amount:.2f}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
logger.info(
|
||||
f"✅ Обработан Stars платеж: пользователь {user_id}, "
|
||||
f"{stars_amount} звезд → {rubles_amount:.2f}₽"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Пользователь с ID {user_id} не найден при обработке Stars платежа")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Stars платежа: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def create_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
receipt_email: Optional[str] = None,
|
||||
receipt_phone: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.yookassa_service:
|
||||
logger.error("YooKassa сервис не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
payment_metadata = metadata or {}
|
||||
payment_metadata.update({
|
||||
"user_id": str(user_id),
|
||||
"amount_kopeks": str(amount_kopeks),
|
||||
"type": "balance_topup"
|
||||
})
|
||||
|
||||
yookassa_response = await self.yookassa_service.create_payment(
|
||||
amount=amount_rubles,
|
||||
currency="RUB",
|
||||
description=description,
|
||||
metadata=payment_metadata,
|
||||
receipt_email=receipt_email,
|
||||
receipt_phone=receipt_phone
|
||||
)
|
||||
|
||||
if not yookassa_response or yookassa_response.get("error"):
|
||||
logger.error(f"Ошибка создания платежа YooKassa: {yookassa_response}")
|
||||
return None
|
||||
|
||||
yookassa_created_at = None
|
||||
if yookassa_response.get("created_at"):
|
||||
try:
|
||||
dt_with_tz = datetime.fromisoformat(
|
||||
yookassa_response["created_at"].replace('Z', '+00:00')
|
||||
)
|
||||
yookassa_created_at = dt_with_tz.replace(tzinfo=None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось парсить created_at: {e}")
|
||||
yookassa_created_at = None
|
||||
|
||||
local_payment = await create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_response["id"],
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency="RUB",
|
||||
description=description,
|
||||
status=yookassa_response["status"],
|
||||
confirmation_url=yookassa_response.get("confirmation_url"),
|
||||
metadata_json=payment_metadata,
|
||||
payment_method_type=None,
|
||||
yookassa_created_at=yookassa_created_at,
|
||||
test_mode=yookassa_response.get("test_mode", False)
|
||||
)
|
||||
|
||||
logger.info(f"Создан платеж YooKassa {yookassa_response['id']} на {amount_rubles}₽ для пользователя {user_id}")
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"yookassa_payment_id": yookassa_response["id"],
|
||||
"confirmation_url": yookassa_response.get("confirmation_url"),
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"amount_rubles": amount_rubles,
|
||||
"status": yookassa_response["status"],
|
||||
"created_at": local_payment.created_at
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания платежа YooKassa: {e}")
|
||||
return None
|
||||
|
||||
async def process_yookassa_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
|
||||
try:
|
||||
from app.database.crud.yookassa import (
|
||||
get_yookassa_payment_by_id,
|
||||
update_yookassa_payment_status,
|
||||
link_yookassa_payment_to_transaction
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
|
||||
payment_object = webhook_data.get("object", {})
|
||||
yookassa_payment_id = payment_object.get("id")
|
||||
status = payment_object.get("status")
|
||||
paid = payment_object.get("paid", False)
|
||||
|
||||
if not yookassa_payment_id:
|
||||
logger.error("Webhook без ID платежа")
|
||||
return False
|
||||
|
||||
payment = await get_yookassa_payment_by_id(db, yookassa_payment_id)
|
||||
if not payment:
|
||||
logger.error(f"Платеж не найден в БД: {yookassa_payment_id}")
|
||||
return False
|
||||
|
||||
captured_at = None
|
||||
if status == "succeeded":
|
||||
captured_at = datetime.utcnow()
|
||||
|
||||
updated_payment = await update_yookassa_payment_status(
|
||||
db,
|
||||
yookassa_payment_id,
|
||||
status,
|
||||
is_paid=paid,
|
||||
is_captured=(status == "succeeded"),
|
||||
captured_at=captured_at,
|
||||
payment_method_type=payment_object.get("payment_method", {}).get("type")
|
||||
)
|
||||
|
||||
if status == "succeeded" and paid and not updated_payment.transaction_id:
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=updated_payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=updated_payment.amount_kopeks,
|
||||
description=f"Пополнение через YooKassa ({yookassa_payment_id[:8]}...)",
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=yookassa_payment_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
await link_yookassa_payment_to_transaction(
|
||||
db, yookassa_payment_id, transaction.id
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
user.balance_kopeks += updated_payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении YooKassa: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(updated_payment.amount_kopeks)}\n"
|
||||
f"🦊 Способ: Банковская карта\n"
|
||||
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks/100:.2f}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
else:
|
||||
logger.error(f"Пользователь с ID {updated_payment.user_id} не найден при пополнении баланса")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки YooKassa webhook: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def _process_successful_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: "YooKassaPayment"
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
transaction_type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через YooKassa: {payment.description}",
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
await link_yookassa_payment_to_transaction(
|
||||
db=db,
|
||||
yookassa_payment_id=payment.yookassa_payment_id,
|
||||
transaction_id=transaction.id
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks/100:.2f}₽")
|
||||
|
||||
logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: "
|
||||
f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}₽")
|
||||
|
||||
if self.bot and user:
|
||||
try:
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
payment.amount_kopeks
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о платеже: {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного платежа YooKassa {payment.yookassa_payment_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_payment_success_notification(
|
||||
self,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int
|
||||
) -> None:
|
||||
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
message = (f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!")
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления пользователю {telegram_id}: {e}")
|
||||
|
||||
async def create_tribute_payment(
|
||||
self,
|
||||
amount_kopeks: int,
|
||||
@@ -104,12 +455,9 @@ class PaymentService:
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
# Здесь должна быть логика обработки платежа
|
||||
# Например, пополнение баланса пользователя
|
||||
|
||||
logger.info(f"Обработан успешный платеж: {payment_id}, {amount_kopeks/100}₽, {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки платежа: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.referral import create_referral_earning
|
||||
from app.database.models import TransactionType
|
||||
from app.database.models import TransactionType, ReferralEarning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_referral_notification(
|
||||
bot: Bot,
|
||||
user_id: int,
|
||||
message: str
|
||||
):
|
||||
try:
|
||||
await bot.send_message(user_id, message, parse_mode="HTML")
|
||||
logger.info(f"✅ Уведомление отправлено пользователю {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки уведомления пользователю {user_id}: {e}")
|
||||
|
||||
|
||||
async def process_referral_registration(
|
||||
db: AsyncSession,
|
||||
new_user_id: int,
|
||||
referrer_id: int
|
||||
referrer_id: int,
|
||||
bot: Bot = None
|
||||
):
|
||||
|
||||
try:
|
||||
new_user = await get_user_by_id(db, new_user_id)
|
||||
referrer = await get_user_by_id(db, referrer_id)
|
||||
|
||||
if not new_user or not referrer:
|
||||
logger.error(f"Пользователи не найдены: {new_user_id}, {referrer_id}")
|
||||
logger.error(f"Пользователи не найдены: new_user_id={new_user_id}, referrer_id={referrer_id}")
|
||||
return False
|
||||
|
||||
if new_user.referred_by_id != referrer_id:
|
||||
logger.error(f"Пользователь {new_user_id} не привязан к рефереру {referrer_id}")
|
||||
return False
|
||||
|
||||
if settings.REFERRED_USER_REWARD > 0:
|
||||
await add_user_balance(
|
||||
db, new_user, settings.REFERRED_USER_REWARD,
|
||||
f"Бонус за регистрацию по реферальной ссылке"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Новый пользователь {new_user_id} получил бонус {settings.REFERRED_USER_REWARD/100}₽")
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer_id,
|
||||
@@ -43,7 +49,26 @@ async def process_referral_registration(
|
||||
reason="referral_registration_pending"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработана реферальная регистрация: {new_user_id} -> {referrer_id}")
|
||||
if bot:
|
||||
referral_notification = (
|
||||
f"🎉 <b>Добро пожаловать!</b>\n\n"
|
||||
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
|
||||
f"💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} "
|
||||
f"вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
|
||||
f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
|
||||
)
|
||||
await send_referral_notification(bot, new_user.telegram_id, referral_notification)
|
||||
|
||||
inviter_notification = (
|
||||
f"👥 <b>Новый реферал!</b>\n\n"
|
||||
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
|
||||
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
|
||||
f"вы получите {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
|
||||
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
|
||||
|
||||
logger.info(f"✅ Зарегистрирован реферал {new_user_id} для {referrer_id}. Бонусы будут выданы после пополнения.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -51,61 +76,155 @@ async def process_referral_registration(
|
||||
return False
|
||||
|
||||
|
||||
async def process_referral_purchase(
|
||||
async def process_referral_topup(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
purchase_amount_kopeks: int,
|
||||
transaction_id: int = None
|
||||
topup_amount_kopeks: int,
|
||||
bot: Bot = None
|
||||
):
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
logger.info(f"Пользователь {user_id} не является рефералом")
|
||||
return False
|
||||
return True
|
||||
|
||||
if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
|
||||
logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума")
|
||||
return True
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
from app.database.crud.referral import get_referral_earnings_by_referral
|
||||
existing_earnings = await get_referral_earnings_by_referral(db, user_id)
|
||||
|
||||
purchase_earnings = [
|
||||
earning for earning in existing_earnings
|
||||
if earning.reason in ["referral_first_purchase", "referral_commission"]
|
||||
]
|
||||
|
||||
is_first_purchase = len(purchase_earnings) == 0
|
||||
|
||||
logger.info(f"🔍 Покупка реферала {user_id}: первая = {is_first_purchase}, сумма = {purchase_amount_kopeks/100}₽")
|
||||
|
||||
if is_first_purchase:
|
||||
reward_amount = settings.REFERRAL_REGISTRATION_REWARD
|
||||
if not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await add_user_balance(
|
||||
db, referrer, reward_amount,
|
||||
f"Реферальная награда за первую покупку {user.full_name}"
|
||||
)
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(
|
||||
ReferralEarning.user_id == referrer.id,
|
||||
ReferralEarning.referral_id == user_id,
|
||||
ReferralEarning.reason == "referral_registration_pending"
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления записи ожидания: {e}")
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=reward_amount,
|
||||
reason="referral_first_purchase",
|
||||
referral_transaction_id=transaction_id
|
||||
)
|
||||
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
|
||||
await add_user_balance(
|
||||
db, user, settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
f"Бонус за первое пополнение по реферальной программе",
|
||||
bot=bot
|
||||
)
|
||||
logger.info(f"💰 Реферал {user_id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS/100}₽")
|
||||
|
||||
if bot:
|
||||
bonus_notification = (
|
||||
f"🎉 <b>Бонус получен!</b>\n\n"
|
||||
f"За первое пополнение вы получили бонус "
|
||||
f"{settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, user.telegram_id, bonus_notification)
|
||||
|
||||
logger.info(f"🎉 Первая покупка реферала: {referrer.telegram_id} получил {reward_amount/100}₽")
|
||||
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
f"Бонус за первое пополнение реферала {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
reason="referral_first_topup"
|
||||
)
|
||||
logger.info(f"💰 Реферер {referrer.telegram_id} получил бонус {settings.REFERRAL_INVITER_BONUS_KOPEKS/100}₽")
|
||||
|
||||
if bot:
|
||||
inviter_bonus_notification = (
|
||||
f"💰 <b>Реферальная награда!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
|
||||
f"🎁 Вы получили награду: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
|
||||
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
commission_amount = int(purchase_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
else:
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки пополнения реферала: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def process_referral_purchase(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
purchase_amount_kopeks: int,
|
||||
transaction_id: int = None,
|
||||
bot: Bot = None
|
||||
):
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
return True
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
|
||||
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!")
|
||||
commission_percent = 10
|
||||
else:
|
||||
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
commission_amount = int(purchase_amount_kopeks * commission_percent / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с покупки {user.full_name}"
|
||||
f"Комиссия {commission_percent}% с покупки {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
@@ -118,6 +237,17 @@ async def process_referral_purchase(
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с покупки: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
purchase_commission_notification = (
|
||||
f"💰 <b>Комиссия с покупки!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> совершил покупку на "
|
||||
f"{settings.format_price(purchase_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({commission_percent}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, purchase_commission_notification)
|
||||
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
@@ -128,47 +258,6 @@ async def process_referral_purchase(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки покупки реферала: {e}")
|
||||
import traceback
|
||||
logger.error(f"Полный traceback: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_referral_stats_for_user(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
try:
|
||||
from app.database.crud.referral import get_referral_earnings_sum
|
||||
from sqlalchemy import select, func
|
||||
from app.database.models import User
|
||||
|
||||
invited_count_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.referred_by_id == user_id)
|
||||
)
|
||||
invited_count = invited_count_result.scalar()
|
||||
|
||||
paid_referrals_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.referred_by_id == user_id,
|
||||
User.has_had_paid_subscription == True
|
||||
)
|
||||
)
|
||||
paid_referrals_count = paid_referrals_result.scalar()
|
||||
|
||||
total_earned = await get_referral_earnings_sum(db, user_id)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
|
||||
|
||||
return {
|
||||
"invited_count": invited_count,
|
||||
"paid_referrals_count": paid_referrals_count,
|
||||
"total_earned_kopeks": total_earned,
|
||||
"month_earned_kopeks": month_earned
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики рефералов: {e}")
|
||||
return {
|
||||
"invited_count": 0,
|
||||
"paid_referrals_count": 0,
|
||||
"total_earned_kopeks": 0,
|
||||
"month_earned_kopeks": 0
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import (
|
||||
@@ -23,8 +25,37 @@ class RemnaWaveService:
|
||||
def __init__(self):
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY
|
||||
api_key=settings.REMNAWAVE_API_KEY,
|
||||
secret_key=settings.REMNAWAVE_SECRET_KEY
|
||||
)
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
if not date_str:
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
try:
|
||||
|
||||
cleaned_date = date_str.strip()
|
||||
|
||||
if cleaned_date.endswith('Z'):
|
||||
cleaned_date = cleaned_date[:-1] + '+00:00'
|
||||
|
||||
if '+00:00+00:00' in cleaned_date:
|
||||
cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00')
|
||||
|
||||
cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date)
|
||||
|
||||
parsed_date = datetime.fromisoformat(cleaned_date)
|
||||
|
||||
if parsed_date.tzinfo is not None:
|
||||
parsed_date = parsed_date.replace(tzinfo=None)
|
||||
|
||||
logger.debug(f"Успешно распарсена дата: {date_str} -> {parsed_date}")
|
||||
return parsed_date
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.")
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
async def get_system_statistics(self) -> Dict[str, Any]:
|
||||
try:
|
||||
@@ -58,7 +89,6 @@ class RemnaWaveService:
|
||||
logger.error(f"Ошибка получения статистики нод: {e}")
|
||||
nodes_stats = {}
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
total_download = sum(node.get('downloadBytes', 0) for node in realtime_usage)
|
||||
total_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage)
|
||||
@@ -199,7 +229,6 @@ class RemnaWaveService:
|
||||
'GBPS': 1024 ** 3
|
||||
}
|
||||
|
||||
import re
|
||||
match = re.match(r'([0-9.,]+)([A-Z]+)', bandwidth_str)
|
||||
if match:
|
||||
value_str = match.group(1).replace(',', '.')
|
||||
@@ -397,212 +426,185 @@ 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, "deleted": 0}
|
||||
|
||||
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
|
||||
|
||||
async with self.api as api:
|
||||
panel_users_data = await api._make_request('GET', '/api/users')
|
||||
panel_users = panel_users_data['response']['users']
|
||||
|
||||
logger.info(f"👥 Найдено пользователей в панели: {len(panel_users)}")
|
||||
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
|
||||
|
||||
async with self.api as api:
|
||||
panel_users = []
|
||||
start = 0
|
||||
size = 100
|
||||
|
||||
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')
|
||||
if not telegram_id:
|
||||
logger.debug(f"➡️ Пропускаем пользователя без telegram_id")
|
||||
continue
|
||||
|
||||
panel_telegram_ids.add(telegram_id)
|
||||
|
||||
logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(panel_users)}: {telegram_id}")
|
||||
while True:
|
||||
logger.info(f"📥 Загружаем пользователей: start={start}, size={size}")
|
||||
|
||||
db_user = bot_users_by_telegram_id.get(telegram_id)
|
||||
response = await api.get_all_users(start=start, size=size)
|
||||
users_batch = response['users']
|
||||
total_users = response['total']
|
||||
|
||||
if not db_user:
|
||||
if sync_type in ["new_only", "all"]:
|
||||
logger.info(f"🆕 Создание пользователя для telegram_id {telegram_id}")
|
||||
|
||||
from app.database.crud.user import create_user
|
||||
logger.info(f"📊 Получено {len(users_batch)} пользователей из {total_users}")
|
||||
|
||||
for user_obj in users_batch:
|
||||
user_dict = {
|
||||
'uuid': user_obj.uuid,
|
||||
'shortUuid': user_obj.short_uuid,
|
||||
'username': user_obj.username,
|
||||
'status': user_obj.status.value,
|
||||
'telegramId': user_obj.telegram_id,
|
||||
'expireAt': user_obj.expire_at.isoformat() + 'Z',
|
||||
'trafficLimitBytes': user_obj.traffic_limit_bytes,
|
||||
'usedTrafficBytes': user_obj.used_traffic_bytes,
|
||||
'hwidDeviceLimit': user_obj.hwid_device_limit,
|
||||
'subscriptionUrl': user_obj.subscription_url,
|
||||
'activeInternalSquads': user_obj.active_internal_squads
|
||||
}
|
||||
panel_users.append(user_dict)
|
||||
|
||||
if len(users_batch) < size:
|
||||
break
|
||||
|
||||
start += size
|
||||
|
||||
if start > total_users:
|
||||
break
|
||||
|
||||
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}
|
||||
|
||||
logger.info(f"📊 Пользователей в боте: {len(bot_users)}")
|
||||
|
||||
panel_users_with_tg = [
|
||||
user for user in panel_users
|
||||
if user.get('telegramId') is not None
|
||||
]
|
||||
|
||||
logger.info(f"📊 Пользователей в панели с Telegram ID: {len(panel_users_with_tg)}")
|
||||
|
||||
panel_telegram_ids = set()
|
||||
|
||||
for i, panel_user in enumerate(panel_users_with_tg):
|
||||
try:
|
||||
telegram_id = panel_user.get('telegramId')
|
||||
if not telegram_id:
|
||||
continue
|
||||
|
||||
panel_telegram_ids.add(telegram_id)
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(panel_users_with_tg)}: {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}")
|
||||
|
||||
db_user = await create_user(
|
||||
db=db,
|
||||
telegram_id=telegram_id,
|
||||
username=panel_user.get('username') or f"user_{telegram_id}",
|
||||
first_name=f"Panel User {telegram_id}",
|
||||
language="ru"
|
||||
)
|
||||
from app.database.crud.user import create_user
|
||||
|
||||
db_user = await create_user(
|
||||
db=db,
|
||||
telegram_id=telegram_id,
|
||||
username=panel_user.get('username') or f"user_{telegram_id}",
|
||||
first_name=f"Panel User {telegram_id}",
|
||||
language="ru"
|
||||
)
|
||||
|
||||
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
|
||||
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
stats["created"] += 1
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с подпиской")
|
||||
|
||||
else:
|
||||
if sync_type in ["update_only", "all"]:
|
||||
logger.debug(f"🔄 Обновление пользователя {telegram_id}")
|
||||
|
||||
if not db_user.remnawave_uuid:
|
||||
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
|
||||
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
stats["created"] += 1
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с подпиской")
|
||||
stats["updated"] += 1
|
||||
logger.debug(f"✅ Обновлён пользователь {telegram_id}")
|
||||
|
||||
else:
|
||||
if sync_type in ["update_only", "all"]:
|
||||
logger.debug(f"🔄 Обновление пользователя {telegram_id}")
|
||||
|
||||
if not db_user.remnawave_uuid:
|
||||
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
|
||||
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
stats["updated"] += 1
|
||||
logger.debug(f"✅ Обновлён пользователь {telegram_id}")
|
||||
|
||||
except Exception as user_error:
|
||||
logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}")
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
except Exception as user_error:
|
||||
logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}")
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
if sync_type == "all":
|
||||
logger.info("🗑️ Деактивация подписок пользователей, отсутствующих в панели...")
|
||||
|
||||
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:
|
||||
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:
|
||||
async with self.api as api:
|
||||
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}")
|
||||
except Exception as hwid_error:
|
||||
logger.error(f"❌ Ошибка сброса HWID устройств для {telegram_id}: {hwid_error}")
|
||||
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from app.database.models import SubscriptionServer
|
||||
|
||||
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
|
||||
)
|
||||
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['deleted']}, ошибок {stats['errors']}")
|
||||
)
|
||||
logger.info(f"🗑️ Удалены серверы подписки для {telegram_id}")
|
||||
except Exception as servers_error:
|
||||
logger.warning(f"⚠️ Не удалось удалить серверы подписки: {servers_error}")
|
||||
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
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.remnawave_short_uuid = None
|
||||
subscription.subscription_url = ""
|
||||
|
||||
db_user.remnawave_uuid = None
|
||||
|
||||
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['deleted']}, ошибок {stats['errors']}")
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка синхронизации пользователей: {e}")
|
||||
return {"created": 0, "updated": 0, "errors": 1, "deleted": 0}
|
||||
|
||||
|
||||
|
||||
async def _create_subscription_from_panel_data(self, db: AsyncSession, user, panel_user):
|
||||
try:
|
||||
from app.database.crud.subscription import create_subscription
|
||||
from app.database.models import SubscriptionStatus
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
|
||||
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)
|
||||
|
||||
else:
|
||||
expire_at = datetime.utcnow() + timedelta(days=30)
|
||||
except Exception as date_error:
|
||||
logger.warning(f"⚠️ Ошибка парсинга даты {expire_at_str}: {date_error}")
|
||||
expire_at = datetime.utcnow() + timedelta(days=30)
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
panel_status = panel_user.get('status', 'ACTIVE')
|
||||
current_time = datetime.utcnow()
|
||||
@@ -672,7 +674,6 @@ class RemnaWaveService:
|
||||
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)
|
||||
|
||||
@@ -683,19 +684,12 @@ class RemnaWaveService:
|
||||
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}")
|
||||
if expire_at_str:
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
if abs((subscription.end_date - expire_at).total_seconds()) > 60:
|
||||
subscription.end_date = expire_at
|
||||
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
|
||||
@@ -1001,7 +995,6 @@ class RemnaWaveService:
|
||||
node_realtime = stats
|
||||
break
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=7)
|
||||
|
||||
@@ -1089,7 +1082,6 @@ class RemnaWaveService:
|
||||
logger.error(f"❌ Ошибка удаления связанных записей: {records_error}")
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
@@ -1213,7 +1205,6 @@ class RemnaWaveService:
|
||||
|
||||
from app.database.crud.subscription import get_all_subscriptions
|
||||
from app.database.models import SubscriptionStatus
|
||||
from datetime import datetime
|
||||
|
||||
page = 1
|
||||
limit = 100
|
||||
@@ -1266,7 +1257,6 @@ class RemnaWaveService:
|
||||
|
||||
from app.database.crud.subscription import get_all_subscriptions
|
||||
from app.database.models import SubscriptionStatus
|
||||
from datetime import datetime
|
||||
|
||||
page = 1
|
||||
limit = 100
|
||||
|
||||
@@ -10,6 +10,12 @@ from app.external.remnawave_api import (
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -229,36 +235,40 @@ class SubscriptionService:
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
|
||||
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
traffic_price = TRAFFIC_PRICES.get(traffic_gb, 0)
|
||||
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
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}₽")
|
||||
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
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * 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}₽")
|
||||
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
|
||||
|
||||
@@ -269,7 +279,7 @@ class SubscriptionService:
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
@@ -277,9 +287,9 @@ class SubscriptionService:
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
|
||||
devices_price = max(0, subscription.device_limit - 1) * settings.PRICE_PER_DEVICE
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
traffic_price = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
total_price = base_price + servers_price + devices_price + traffic_price
|
||||
|
||||
@@ -364,7 +374,7 @@ class SubscriptionService:
|
||||
prices_list.append(price)
|
||||
logger.debug(f"🏷️ Страна {server.display_name}: {price/100}₽")
|
||||
else:
|
||||
default_price = 1000
|
||||
default_price = 0
|
||||
total_price += default_price
|
||||
prices_list.append(default_price)
|
||||
logger.warning(f"⚠️ Сервер {country_uuid} недоступен, используем базовую цену: {default_price/100}₽")
|
||||
@@ -374,7 +384,7 @@ class SubscriptionService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения цен стран: {e}")
|
||||
default_prices = [1000] * len(country_uuids)
|
||||
default_prices = [0] * len(country_uuids)
|
||||
return sum(default_prices), default_prices
|
||||
|
||||
async def _get_countries_price(self, country_uuids: List[str], db: AsyncSession) -> int:
|
||||
@@ -384,6 +394,139 @@ class SubscriptionService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения цен стран: {e}")
|
||||
return len(country_uuids) * 1000
|
||||
|
||||
async def calculate_subscription_price_with_months(
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
|
||||
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
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_price_per_month = server.price_kopeks
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
server_prices.append(server_price_total)
|
||||
total_servers_price += server_price_total
|
||||
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽")
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price_with_months(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_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 calculate_addon_price_with_remaining_period(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None,
|
||||
db: AsyncSession = None
|
||||
) -> int:
|
||||
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
total_price = 0
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
total_price += traffic_price_per_month * months_to_pay
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_price += devices_price_per_month * months_to_pay
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
if additional_server_ids and db:
|
||||
for server_id in additional_server_ids:
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_total_price = server_price_per_month * months_to_pay
|
||||
total_price += server_total_price
|
||||
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}₽")
|
||||
|
||||
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}₽")
|
||||
return total_price
|
||||
|
||||
def _gb_to_bytes(self, gb: int) -> int:
|
||||
if gb == 0:
|
||||
|
||||
@@ -48,7 +48,6 @@ class TributeService:
|
||||
if not payment_url:
|
||||
return None
|
||||
|
||||
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
@@ -91,19 +90,28 @@ class TributeService:
|
||||
return {"status": "ok", "event": event_type}
|
||||
|
||||
async def _handle_successful_payment(self, payment_data: Dict[str, Any]):
|
||||
"""Обработка успешного платежа - ПОЛНОСТЬЮ ПЕРЕРАБОТАННАЯ ВЕРСИЯ"""
|
||||
|
||||
try:
|
||||
user_id = payment_data["user_id"]
|
||||
amount_kopeks = payment_data["amount_kopeks"]
|
||||
payment_id = payment_data["payment_id"]
|
||||
|
||||
logger.info(f"Обрабатываем успешный Tribute платеж: user_id={user_id}, amount={amount_kopeks}, payment_id={payment_id}")
|
||||
|
||||
async for session in get_db():
|
||||
existing_transaction = await get_transaction_by_external_id(
|
||||
session, f"donation_{payment_id}", PaymentMethod.TRIBUTE
|
||||
from app.database.crud.transaction import check_tribute_payment_duplicate, create_unique_tribute_transaction
|
||||
|
||||
duplicate_transaction = await check_tribute_payment_duplicate(
|
||||
session, payment_id, amount_kopeks, user_id
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
logger.warning(f"Транзакция с donation_request_id {payment_id} уже существует")
|
||||
if duplicate_transaction:
|
||||
logger.warning(f"Найден дубликат платежа:")
|
||||
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
|
||||
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
|
||||
logger.warning(f" Created: {duplicate_transaction.created_at}")
|
||||
logger.warning(f"Платеж игнорирован")
|
||||
return
|
||||
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
@@ -111,29 +119,34 @@ class TributeService:
|
||||
logger.error(f"Пользователь {user_id} не найден")
|
||||
return
|
||||
|
||||
transaction = await create_transaction(
|
||||
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
|
||||
|
||||
transaction = await create_unique_tribute_transaction(
|
||||
db=session,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
user_id=user.id,
|
||||
payment_id=payment_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Tribute: {amount_kopeks/100}₽",
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=f"donation_{payment_id}",
|
||||
is_completed=True
|
||||
description=f"Пополнение через Tribute: {amount_kopeks/100}₽ (ID: {payment_id})"
|
||||
)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Баланс пользователя {user_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
|
||||
|
||||
await self._send_success_notification(user_id, amount_kopeks)
|
||||
|
||||
logger.info(f"Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного Tribute платежа: {e}")
|
||||
logger.error(f"Ошибка обработки успешного Tribute платежа: {e}", exc_info=True)
|
||||
|
||||
async def _handle_failed_payment(self, payment_data: Dict[str, Any]):
|
||||
"""Обработка неудачного платежа"""
|
||||
|
||||
try:
|
||||
user_id = payment_data["user_id"]
|
||||
@@ -141,7 +154,7 @@ class TributeService:
|
||||
|
||||
async for session in get_db():
|
||||
transaction = await get_transaction_by_external_id(
|
||||
session, payment_id, PaymentMethod.TRIBUTE
|
||||
session, f"donation_{payment_id}", PaymentMethod.TRIBUTE
|
||||
)
|
||||
|
||||
if transaction:
|
||||
@@ -157,6 +170,7 @@ class TributeService:
|
||||
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
|
||||
|
||||
async def _handle_refund(self, refund_data: Dict[str, Any]):
|
||||
"""Обработка возврата"""
|
||||
|
||||
try:
|
||||
user_id = refund_data["user_id"]
|
||||
@@ -189,6 +203,7 @@ class TributeService:
|
||||
logger.error(f"Ошибка обработки возврата Tribute: {e}")
|
||||
|
||||
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
|
||||
"""Отправка уведомления об успешном платеже"""
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
@@ -217,6 +232,7 @@ class TributeService:
|
||||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||||
|
||||
async def _send_failure_notification(self, user_id: int):
|
||||
"""Отправка уведомления о неудачном платеже"""
|
||||
|
||||
try:
|
||||
text = (
|
||||
@@ -245,6 +261,7 @@ class TributeService:
|
||||
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
|
||||
|
||||
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
|
||||
"""Отправка уведомления о возврате"""
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
@@ -272,6 +289,54 @@ class TributeService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о возврате: {e}")
|
||||
|
||||
async def force_process_payment(
|
||||
self,
|
||||
payment_id: str,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Принудительная обработка Tribute платежа"
|
||||
) -> bool:
|
||||
"""Принудительная обработка платежа (для отладки)"""
|
||||
|
||||
try:
|
||||
logger.info(f"🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}")
|
||||
|
||||
async for session in get_db():
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if not user:
|
||||
logger.error(f"❌ Пользователь {user_id} не найден")
|
||||
return False
|
||||
|
||||
external_id = f"force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=session,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=external_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп")
|
||||
|
||||
await self._send_success_notification(user_id, amount_kopeks)
|
||||
|
||||
logger.info(f"✅ Принудительно обработан платеж {payment_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка принудительной обработки: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
return await self.tribute_api.get_payment_status(payment_id)
|
||||
|
||||
@@ -281,4 +346,4 @@ class TributeService:
|
||||
amount_kopeks: Optional[int] = None,
|
||||
reason: str = "Возврат по запросу"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
|
||||
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
|
||||
|
||||
@@ -2,7 +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 sqlalchemy import delete, select, update
|
||||
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
@@ -13,7 +13,7 @@ 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, Subscription, Transaction, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -135,16 +135,15 @@ class UserService:
|
||||
return False
|
||||
|
||||
if amount_kopeks > 0:
|
||||
await add_user_balance(db, user, amount_kopeks, description)
|
||||
await add_user_balance(db, user, amount_kopeks, description=description)
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
return True
|
||||
else:
|
||||
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽")
|
||||
return success
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя: {e}")
|
||||
return False
|
||||
@@ -245,7 +244,96 @@ class UserService:
|
||||
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}")
|
||||
logger.warning(f"⚠️ Ошибка деактивации RemnaWave: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import YooKassaPayment
|
||||
from sqlalchemy import select
|
||||
|
||||
yookassa_result = await db.execute(
|
||||
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
yookassa_payments = yookassa_result.scalars().all()
|
||||
|
||||
if yookassa_payments:
|
||||
logger.info(f"🔄 Удаляем {len(yookassa_payments)} YooKassa платежей")
|
||||
await db.execute(
|
||||
delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ YooKassa платежи удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления YooKassa платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
transactions = transactions_result.scalars().all()
|
||||
|
||||
if transactions:
|
||||
logger.info(f"🔄 Удаляем {len(transactions)} транзакций")
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ Транзакции удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления транзакций: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
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)
|
||||
)
|
||||
await db.flush()
|
||||
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)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import BroadcastHistory
|
||||
await db.execute(
|
||||
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалена история рассылок админа {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import SubscriptionConversion
|
||||
conversions_result = await db.execute(
|
||||
select(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
|
||||
)
|
||||
conversions = conversions_result.scalars().all()
|
||||
|
||||
if conversions:
|
||||
logger.info(f"🔄 Удаляем {len(conversions)} записей конверсий")
|
||||
await db.execute(
|
||||
delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ Записи конверсий удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления записей конверсий: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
@@ -254,74 +342,47 @@ class UserService:
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
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)
|
||||
from app.database.models import Subscription
|
||||
await db.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
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(
|
||||
referrals_result = await db.execute(
|
||||
update(User)
|
||||
.where(User.referred_by_id == user.id)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
logger.info(f"🗑️ Очищены реферальные ссылки у рефералов пользователя {user_id}")
|
||||
if referrals_result.rowcount > 0:
|
||||
logger.info(f"🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user.id)
|
||||
delete(User).where(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} помечен как удаленный и обнулен")
|
||||
logger.info(f"✅ Пользователь {user_id} окончательно удален из базы")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления статуса пользователя: {e}")
|
||||
logger.error(f"❌ Ошибка финального удаления пользователя: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
logger.info(f"✅ Пользователь {user_id} (Telegram ID: {user.telegram_id}) полностью удален админом {admin_id}")
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} (ID: {user_id}) полностью удален администратором {admin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import uuid
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from yookassa import Configuration, Payment as YooKassaPayment
|
||||
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
||||
from yookassa.domain.common.confirmation_type import ConfirmationType
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YooKassaService:
|
||||
|
||||
def __init__(self,
|
||||
shop_id: Optional[str] = None,
|
||||
secret_key: Optional[str] = None,
|
||||
configured_return_url: Optional[str] = None,
|
||||
bot_username_for_default_return: Optional[str] = None):
|
||||
|
||||
shop_id = shop_id or getattr(settings, 'YOOKASSA_SHOP_ID', None)
|
||||
secret_key = secret_key or getattr(settings, 'YOOKASSA_SECRET_KEY', None)
|
||||
configured_return_url = configured_return_url or getattr(settings, 'YOOKASSA_RETURN_URL', None)
|
||||
|
||||
if not shop_id or not secret_key:
|
||||
logger.warning(
|
||||
"YooKassa SHOP_ID или SECRET_KEY не настроены в settings. "
|
||||
"Функционал платежей будет ОТКЛЮЧЕН.")
|
||||
self.configured = False
|
||||
else:
|
||||
try:
|
||||
Configuration.configure(shop_id, secret_key)
|
||||
self.configured = True
|
||||
logger.info(
|
||||
f"YooKassa SDK сконфигурирован для shop_id: {shop_id[:5]}...")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка конфигурации YooKassa SDK: {e}",
|
||||
exc_info=True)
|
||||
self.configured = False
|
||||
|
||||
if configured_return_url:
|
||||
self.return_url = configured_return_url
|
||||
elif bot_username_for_default_return:
|
||||
self.return_url = f"https://t.me/{bot_username_for_default_return}"
|
||||
logger.info(
|
||||
f"YOOKASSA_RETURN_URL не установлен, используем бота: {self.return_url}")
|
||||
else:
|
||||
self.return_url = "https://t.me/"
|
||||
logger.warning(
|
||||
f"КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. "
|
||||
f"Используем заглушку: {self.return_url}. Платежи могут работать некорректно.")
|
||||
|
||||
logger.info(f"YooKassa Service return_url: {self.return_url}")
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: str,
|
||||
metadata: Dict[str, Any],
|
||||
receipt_email: Optional[str] = None,
|
||||
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Создает платеж в YooKassa"""
|
||||
|
||||
if not self.configured:
|
||||
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж.")
|
||||
return None
|
||||
|
||||
customer_contact_for_receipt = {}
|
||||
if receipt_email:
|
||||
customer_contact_for_receipt["email"] = receipt_email
|
||||
elif receipt_phone:
|
||||
customer_contact_for_receipt["phone"] = receipt_phone
|
||||
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
||||
customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
else:
|
||||
logger.error(
|
||||
"КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.")
|
||||
return {
|
||||
"error": True,
|
||||
"internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию."
|
||||
}
|
||||
|
||||
try:
|
||||
builder = PaymentRequestBuilder()
|
||||
builder.set_amount({
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
})
|
||||
builder.set_capture(True)
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
|
||||
receipt_items_list: List[Dict[str, Any]] = [{
|
||||
"description": description[:128],
|
||||
"quantity": "1.00",
|
||||
"amount": {
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
},
|
||||
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
|
||||
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
|
||||
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
|
||||
}]
|
||||
|
||||
receipt_data_dict: Dict[str, Any] = {
|
||||
"customer": customer_contact_for_receipt,
|
||||
"items": receipt_items_list
|
||||
}
|
||||
|
||||
builder.set_receipt(receipt_data_dict)
|
||||
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
payment_request = builder.build()
|
||||
|
||||
logger.info(
|
||||
f"Создание платежа YooKassa (Idempotence-Key: {idempotence_key}). "
|
||||
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
|
||||
|
||||
logger.info(
|
||||
f"Ответ YooKassa Payment.create: ID={response.id}, Status={response.status}, Paid={response.paid}")
|
||||
|
||||
return {
|
||||
"id": response.id,
|
||||
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
|
||||
"status": response.status,
|
||||
"metadata": response.metadata,
|
||||
"amount_value": float(response.amount.value),
|
||||
"amount_currency": response.amount.currency,
|
||||
"idempotence_key_used": idempotence_key,
|
||||
"paid": response.paid,
|
||||
"refundable": response.refundable,
|
||||
"created_at": response.created_at.isoformat() if hasattr(
|
||||
response.created_at, 'isoformat') else str(response.created_at),
|
||||
"description_from_yk": response.description,
|
||||
"test_mode": response.test if hasattr(response, 'test') else None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания платежа YooKassa: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_payment_info(
|
||||
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.configured:
|
||||
logger.error("YooKassa не сконфигурирован. Невозможно получить информацию о платеже.")
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.info(f"Получение информации о платеже YooKassa ID: {payment_id_in_yookassa}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
payment_info_yk = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
|
||||
|
||||
if payment_info_yk:
|
||||
logger.info(
|
||||
f"Информация о платеже YooKassa {payment_id_in_yookassa}: "
|
||||
f"Status={payment_info_yk.status}, Paid={payment_info_yk.paid}")
|
||||
return {
|
||||
"id": payment_info_yk.id,
|
||||
"status": payment_info_yk.status,
|
||||
"paid": payment_info_yk.paid,
|
||||
"amount_value": float(payment_info_yk.amount.value),
|
||||
"amount_currency": payment_info_yk.amount.currency,
|
||||
"metadata": payment_info_yk.metadata,
|
||||
"description": payment_info_yk.description,
|
||||
"refundable": payment_info_yk.refundable,
|
||||
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
|
||||
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
|
||||
"captured_at": payment_info_yk.captured_at.isoformat()
|
||||
if payment_info_yk.captured_at and hasattr(
|
||||
payment_info_yk.captured_at, 'isoformat') else None,
|
||||
"payment_method_type": payment_info_yk.payment_method.type
|
||||
if payment_info_yk.payment_method else None,
|
||||
"test_mode": payment_info_yk.test if hasattr(payment_info_yk, 'test') else None
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Платеж не найден в YooKassa ID: {payment_id_in_yookassa}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о платеже YooKassa {payment_id_in_yookassa}: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
@@ -52,6 +52,8 @@ class AdminStates(StatesGroup):
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
editing_device_price = State()
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
"""
|
||||
Утилиты
|
||||
"""
|
||||
from .pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
format_period_description
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'calculate_months_from_days',
|
||||
'get_remaining_months',
|
||||
'calculate_prorated_price',
|
||||
'format_period_description'
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_months_from_days(days: int) -> int:
|
||||
return max(1, round(days / 30))
|
||||
|
||||
|
||||
def get_remaining_months(end_date: datetime) -> int:
|
||||
current_time = datetime.utcnow()
|
||||
if end_date <= current_time:
|
||||
return 1
|
||||
|
||||
remaining_days = (end_date - current_time).days
|
||||
return max(1, round(remaining_days / 30))
|
||||
|
||||
|
||||
def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
|
||||
exact_months = period_days / 30
|
||||
months_count = max(1, round(exact_months))
|
||||
|
||||
logger.debug(f"Период {period_days} дней = {exact_months:.2f} точных месяцев ≈ {months_count} месяцев для расчета")
|
||||
|
||||
return months_count, exact_months
|
||||
|
||||
|
||||
def calculate_prorated_price(
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
min_charge_months: int = 1
|
||||
) -> Tuple[int, int]:
|
||||
months_remaining = get_remaining_months(end_date)
|
||||
months_to_charge = max(min_charge_months, months_remaining)
|
||||
|
||||
total_price = monthly_price * months_to_charge
|
||||
|
||||
logger.debug(f"Расчет пропорциональной цены: {monthly_price/100}₽/мес × {months_to_charge} мес = {total_price/100}₽")
|
||||
|
||||
return total_price, months_to_charge
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
if language == "ru":
|
||||
if days == 30:
|
||||
return "1 месяц"
|
||||
elif days == 60:
|
||||
return "2 месяца"
|
||||
elif days == 90:
|
||||
return "3 месяца"
|
||||
elif days == 180:
|
||||
return "6 месяцев"
|
||||
elif days == 360:
|
||||
return "12 месяцев"
|
||||
else:
|
||||
month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев")
|
||||
return f"{days} дней ({months} {month_word})"
|
||||
else:
|
||||
month_word = "month" if months == 1 else "months"
|
||||
return f"{days} days ({months} {month_word})"
|
||||
|
||||
|
||||
def validate_pricing_calculation(
|
||||
base_price: int,
|
||||
monthly_additions: int,
|
||||
months: int,
|
||||
total_calculated: int
|
||||
) -> bool:
|
||||
expected_total = base_price + (monthly_additions * months)
|
||||
is_valid = expected_total == total_calculated
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"Несоответствие в расчете цены: ожидалось {expected_total/100}₽, получено {total_calculated/100}₽")
|
||||
logger.warning(f"Детали: базовая цена {base_price/100}₽ + месячные дополнения {monthly_additions/100}₽ × {months} мес")
|
||||
|
||||
return is_valid
|
||||
|
||||
|
||||
STANDARD_PERIODS = {
|
||||
14: {"months": 0.5, "display_ru": "2 недели", "display_en": "2 weeks"},
|
||||
30: {"months": 1, "display_ru": "1 месяц", "display_en": "1 month"},
|
||||
60: {"months": 2, "display_ru": "2 месяца", "display_en": "2 months"},
|
||||
90: {"months": 3, "display_ru": "3 месяца", "display_en": "3 months"},
|
||||
180: {"months": 6, "display_ru": "6 месяцев", "display_en": "6 months"},
|
||||
360: {"months": 12, "display_ru": "1 год", "display_en": "1 year"},
|
||||
}
|
||||
|
||||
|
||||
def get_period_info(days: int) -> dict:
|
||||
return STANDARD_PERIODS.get(days)
|
||||
@@ -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
|
||||
@@ -1,76 +1,298 @@
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database.models import User
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, ReferralEarning, Transaction, TransactionType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user: User
|
||||
) -> None:
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
|
||||
|
||||
|
||||
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
|
||||
max_attempts = 10
|
||||
|
||||
base_code = str(telegram_id)[-6:]
|
||||
|
||||
for attempt in range(10):
|
||||
if attempt == 0:
|
||||
referral_code = base_code
|
||||
else:
|
||||
suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=2))
|
||||
referral_code = base_code + suffix
|
||||
for _ in range(max_attempts):
|
||||
code = f"ref{''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))}"
|
||||
|
||||
result = await db.execute(
|
||||
select(User.id).where(User.referral_code == referral_code)
|
||||
select(User).where(User.referral_code == code)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
return code
|
||||
|
||||
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(db: AsyncSession, user_id: int) -> bool:
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден")
|
||||
return False
|
||||
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отметки пользователя {user_id} как имевшего платную подписку: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_referral_summary(db: AsyncSession, user_id: int) -> Dict:
|
||||
try:
|
||||
invited_count_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.referred_by_id == user_id)
|
||||
)
|
||||
invited_count = invited_count_result.scalar() or 0
|
||||
|
||||
referrals_result = await db.execute(
|
||||
select(User).where(User.referred_by_id == user_id)
|
||||
)
|
||||
referrals = referrals_result.scalars().all()
|
||||
|
||||
paid_referrals_count = sum(1 for ref in referrals if ref.has_made_first_topup)
|
||||
|
||||
total_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
total_earned_kopeks = total_earnings_result.scalar() or 0
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == user_id,
|
||||
ReferralEarning.created_at >= month_ago
|
||||
)
|
||||
)
|
||||
)
|
||||
month_earned_kopeks = month_earnings_result.scalar() or 0
|
||||
|
||||
recent_earnings_result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.options(selectinload(ReferralEarning.referral))
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_earnings_raw = recent_earnings_result.scalars().all()
|
||||
|
||||
recent_earnings = []
|
||||
for earning in recent_earnings_raw:
|
||||
if earning.referral:
|
||||
recent_earnings.append({
|
||||
'amount_kopeks': earning.amount_kopeks,
|
||||
'reason': earning.reason,
|
||||
'referral_name': earning.referral.full_name,
|
||||
'created_at': earning.created_at
|
||||
})
|
||||
|
||||
earnings_by_type = {}
|
||||
earnings_by_type_result = await db.execute(
|
||||
select(
|
||||
ReferralEarning.reason,
|
||||
func.count(ReferralEarning.id).label('count'),
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_amount')
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.group_by(ReferralEarning.reason)
|
||||
)
|
||||
|
||||
if not result.scalar():
|
||||
return referral_code
|
||||
|
||||
import uuid
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
async def get_user_referral_summary(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
try:
|
||||
from app.services.referral_service import get_referral_stats_for_user
|
||||
from app.database.crud.referral import get_referral_earnings_by_user
|
||||
for row in earnings_by_type_result:
|
||||
earnings_by_type[row.reason] = {
|
||||
'count': row.count,
|
||||
'total_amount_kopeks': row.total_amount
|
||||
}
|
||||
|
||||
stats = await get_referral_stats_for_user(db, user_id)
|
||||
|
||||
recent_earnings = await get_referral_earnings_by_user(db, user_id, limit=5)
|
||||
active_referrals_count = 0
|
||||
for referral in referrals:
|
||||
if referral.last_activity and referral.last_activity >= month_ago:
|
||||
active_referrals_count += 1
|
||||
|
||||
return {
|
||||
**stats,
|
||||
"recent_earnings": [
|
||||
{
|
||||
"amount_kopeks": earning.amount_kopeks,
|
||||
"reason": earning.reason,
|
||||
"created_at": earning.created_at,
|
||||
"referral_name": earning.referral.full_name if earning.referral else "Неизвестно"
|
||||
}
|
||||
for earning in recent_earnings
|
||||
]
|
||||
'invited_count': invited_count,
|
||||
'paid_referrals_count': paid_referrals_count,
|
||||
'active_referrals_count': active_referrals_count,
|
||||
'total_earned_kopeks': total_earned_kopeks,
|
||||
'month_earned_kopeks': month_earned_kopeks,
|
||||
'recent_earnings': recent_earnings,
|
||||
'earnings_by_type': earnings_by_type,
|
||||
'conversion_rate': round((paid_referrals_count / invited_count * 100) if invited_count > 0 else 0, 1)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сводки рефералов: {e}")
|
||||
logger.error(f"Ошибка получения статистики рефералов для пользователя {user_id}: {e}")
|
||||
return {
|
||||
"invited_count": 0,
|
||||
"paid_referrals_count": 0,
|
||||
"total_earned_kopeks": 0,
|
||||
"month_earned_kopeks": 0,
|
||||
"recent_earnings": []
|
||||
}
|
||||
'invited_count': 0,
|
||||
'paid_referrals_count': 0,
|
||||
'active_referrals_count': 0,
|
||||
'total_earned_kopeks': 0,
|
||||
'month_earned_kopeks': 0,
|
||||
'recent_earnings': [],
|
||||
'earnings_by_type': {},
|
||||
'conversion_rate': 0.0
|
||||
}
|
||||
|
||||
|
||||
async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int = 20, offset: int = 0) -> Dict:
|
||||
try:
|
||||
referrals_result = await db.execute(
|
||||
select(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
referrals = referrals_result.scalars().all()
|
||||
|
||||
total_count_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.referred_by_id == user_id)
|
||||
)
|
||||
total_count = total_count_result.scalar() or 0
|
||||
|
||||
detailed_referrals = []
|
||||
for referral in referrals:
|
||||
earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == user_id,
|
||||
ReferralEarning.referral_id == referral.id
|
||||
)
|
||||
)
|
||||
)
|
||||
total_earned_from_referral = earnings_result.scalar() or 0
|
||||
|
||||
topups_result = await db.execute(
|
||||
select(func.count(Transaction.id))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id == referral.id,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True
|
||||
)
|
||||
)
|
||||
)
|
||||
topups_count = topups_result.scalar() or 0
|
||||
|
||||
days_since_registration = (datetime.utcnow() - referral.created_at).days
|
||||
|
||||
days_since_activity = None
|
||||
if referral.last_activity:
|
||||
days_since_activity = (datetime.utcnow() - referral.last_activity).days
|
||||
|
||||
detailed_referrals.append({
|
||||
'id': referral.id,
|
||||
'telegram_id': referral.telegram_id,
|
||||
'full_name': referral.full_name,
|
||||
'username': referral.username,
|
||||
'created_at': referral.created_at,
|
||||
'last_activity': referral.last_activity,
|
||||
'has_made_first_topup': referral.has_made_first_topup,
|
||||
'balance_kopeks': referral.balance_kopeks,
|
||||
'total_earned_kopeks': total_earned_from_referral,
|
||||
'topups_count': topups_count,
|
||||
'days_since_registration': days_since_registration,
|
||||
'days_since_activity': days_since_activity,
|
||||
'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive'
|
||||
})
|
||||
|
||||
return {
|
||||
'referrals': detailed_referrals,
|
||||
'total_count': total_count,
|
||||
'has_next': offset + limit < total_count,
|
||||
'has_prev': offset > 0,
|
||||
'current_page': (offset // limit) + 1,
|
||||
'total_pages': (total_count + limit - 1) // limit
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка рефералов для пользователя {user_id}: {e}")
|
||||
return {
|
||||
'referrals': [],
|
||||
'total_count': 0,
|
||||
'has_next': False,
|
||||
'has_prev': False,
|
||||
'current_page': 1,
|
||||
'total_pages': 1
|
||||
}
|
||||
|
||||
|
||||
async def get_referral_analytics(db: AsyncSession, user_id: int) -> Dict:
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
periods = {
|
||||
'today': now.replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
'week': now - timedelta(days=7),
|
||||
'month': now - timedelta(days=30),
|
||||
'quarter': now - timedelta(days=90)
|
||||
}
|
||||
|
||||
earnings_by_period = {}
|
||||
for period_name, start_date in periods.items():
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == user_id,
|
||||
ReferralEarning.created_at >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
earnings_by_period[period_name] = result.scalar() or 0
|
||||
|
||||
top_referrals_result = await db.execute(
|
||||
select(
|
||||
ReferralEarning.referral_id,
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_earned'),
|
||||
func.count(ReferralEarning.id).label('earnings_count')
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.group_by(ReferralEarning.referral_id)
|
||||
.order_by(func.sum(ReferralEarning.amount_kopeks).desc())
|
||||
.limit(5)
|
||||
)
|
||||
|
||||
top_referrals = []
|
||||
for row in top_referrals_result:
|
||||
referral_result = await db.execute(
|
||||
select(User).where(User.id == row.referral_id)
|
||||
)
|
||||
referral = referral_result.scalar_one_or_none()
|
||||
if referral:
|
||||
top_referrals.append({
|
||||
'referral_name': referral.full_name,
|
||||
'total_earned_kopeks': row.total_earned,
|
||||
'earnings_count': row.earnings_count
|
||||
})
|
||||
|
||||
return {
|
||||
'earnings_by_period': earnings_by_period,
|
||||
'top_referrals': top_referrals
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения аналитики рефералов для пользователя {user_id}: {e}")
|
||||
return {
|
||||
'earnings_by_period': {
|
||||
'today': 0,
|
||||
'week': 0,
|
||||
'month': 0,
|
||||
'quarter': 0
|
||||
},
|
||||
'top_referrals': []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: remnawave_bot_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
bot:
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-remnawave_user}:${POSTGRES_PASSWORD:-secure_password_123}@postgres:5432/${POSTGRES_DB:-remnawave_bot}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
volumes:
|
||||
- ./logs:/app/logs:rw
|
||||
- ./data:/app/data:rw
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
networks:
|
||||
- bot_network
|
||||
user: "1000:1000"
|
||||
command: >
|
||||
bash -c "
|
||||
mkdir -p /app/logs /app/data &&
|
||||
python main.py
|
||||
"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
@@ -9,7 +11,21 @@ from app.bot import setup_bot
|
||||
from app.config import settings
|
||||
from app.database.database import init_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.webhook_server import WebhookServer
|
||||
from app.external.yookassa_webhook import start_yookassa_webhook_server
|
||||
from app.database.universal_migration import run_universal_migration
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
|
||||
def __init__(self):
|
||||
self.exit = False
|
||||
|
||||
def exit_gracefully(self, signum, frame):
|
||||
logging.getLogger(__name__).info(f"Получен сигнал {signum}. Корректное завершение работы...")
|
||||
self.exit = True
|
||||
|
||||
|
||||
async def main():
|
||||
@@ -23,52 +39,173 @@ async def main():
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🚀 Запуск VPN бота...")
|
||||
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
|
||||
|
||||
killer = GracefulExit()
|
||||
signal.signal(signal.SIGINT, killer.exit_gracefully)
|
||||
signal.signal(signal.SIGTERM, killer.exit_gracefully)
|
||||
|
||||
webhook_server = None
|
||||
yookassa_server_task = None
|
||||
monitoring_task = None
|
||||
maintenance_task = None
|
||||
polling_task = None
|
||||
|
||||
try:
|
||||
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
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
# Инициализируем webhook сервер если Tribute включен
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info("🌐 Запуск webhook сервера для Tribute...")
|
||||
logger.info("🌐 Запуск Tribute webhook сервера...")
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
else:
|
||||
logger.info("ℹ️ Tribute отключен, webhook сервер не запускается")
|
||||
|
||||
logger.info("🔍 Запуск службы мониторинга...")
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info("💳 Запуск YooKassa webhook сервера...")
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
else:
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
|
||||
logger.info("📊 Запуск службы мониторинга...")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
logger.info("🔧 Проверка службы техработ...")
|
||||
if not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
logger.info("🔧 Запуск службы техработ...")
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
else:
|
||||
logger.info("🔧 Служба техработ уже запущена")
|
||||
maintenance_task = None
|
||||
|
||||
logger.info("🔄 Запуск polling...")
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("🎯 Активные webhook endpoints:")
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
try:
|
||||
await asyncio.gather(
|
||||
dp.start_polling(bot),
|
||||
monitoring_task
|
||||
)
|
||||
while not killer.exit:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if yookassa_server_task and yookassa_server_task.done():
|
||||
exception = yookassa_server_task.exception()
|
||||
if exception:
|
||||
logger.error(f"YooKassa webhook сервер завершился с ошибкой: {exception}")
|
||||
logger.info("🔄 Перезапуск YooKassa webhook сервера...")
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
|
||||
if monitoring_task.done():
|
||||
exception = monitoring_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Служба мониторинга завершилась с ошибкой: {exception}")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
if maintenance_task and maintenance_task.done():
|
||||
exception = maintenance_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Служба техработ завершилась с ошибкой: {exception}")
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
|
||||
if polling_task.done():
|
||||
exception = polling_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Polling завершился с ошибкой: {exception}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в основном цикле: {e}")
|
||||
monitoring_service.stop_monitoring()
|
||||
if webhook_server:
|
||||
await webhook_server.stop()
|
||||
raise
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка при запуске: {e}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
logger.info("🛑 Завершение работы бота")
|
||||
monitoring_service.stop_monitoring()
|
||||
logger.info("🛑 Начинается корректное завершение работы...")
|
||||
|
||||
if yookassa_server_task and not yookassa_server_task.done():
|
||||
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
|
||||
yookassa_server_task.cancel()
|
||||
try:
|
||||
await yookassa_server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if monitoring_task and not monitoring_task.done():
|
||||
logger.info("ℹ️ Остановка службы мониторинга...")
|
||||
monitoring_service.stop_monitoring()
|
||||
monitoring_task.cancel()
|
||||
try:
|
||||
await monitoring_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if maintenance_task and not maintenance_task.done():
|
||||
logger.info("ℹ️ Остановка службы техработ...")
|
||||
await maintenance_service.stop_monitoring()
|
||||
maintenance_task.cancel()
|
||||
try:
|
||||
await maintenance_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if polling_task and not polling_task.done():
|
||||
logger.info("ℹ️ Остановка polling...")
|
||||
polling_task.cancel()
|
||||
try:
|
||||
await polling_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if webhook_server:
|
||||
logger.info("ℹ️ Остановка Tribute webhook сервера...")
|
||||
await webhook_server.stop()
|
||||
|
||||
if 'bot' in locals():
|
||||
try:
|
||||
await bot.session.close()
|
||||
logger.info("✅ Сессия бота закрыта")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка закрытия сессии бота: {e}")
|
||||
|
||||
logger.info("✅ Завершение работы бота завершено")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -78,4 +215,4 @@ if __name__ == "__main__":
|
||||
print("\n🛑 Бот остановлен пользователем")
|
||||
except Exception as e:
|
||||
print(f"❌ Критическая ошибка: {e}")
|
||||
sys.exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -12,9 +12,15 @@ pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
redis==5.0.1
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.0.0
|
||||
|
||||
# Логирование и мониторинг
|
||||
structlog==23.2.0
|
||||
|
||||
# Планировщик задач для техработ
|
||||
APScheduler==3.10.4
|
||||
|
||||
# Утилиты
|
||||
python-dateutil==2.8.2
|
||||
pytz==2023.4
|
||||
|
||||
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 463 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 240 KiB |