Compare commits
528 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 0b7d8d41f6 | |||
| 4ca46537f9 | |||
| 75f2d0f1ce | |||
| 51424bf256 | |||
| 4cce2f1efb | |||
| 5102286963 | |||
| 24c8f25e6e | |||
| de4796ac1c | |||
| 95a8da5f19 | |||
| 76f1313550 | |||
| 58d22200cf | |||
| eb8b09e658 | |||
| 25cf635b6f | |||
| a54c1001f9 | |||
| 6140d7042a | |||
| d0135a2328 | |||
| 1048734548 | |||
| 62116fd5ba | |||
| 37a905f056 | |||
| bce9061401 | |||
| 118b282a2b | |||
| 6c250ed36f | |||
| 3842aeacca | |||
| 1d1afbe1da | |||
| d4db55ea66 | |||
| 354964060c | |||
| 5bc8d8e52c | |||
| 8f3e8bacc4 | |||
| 61c94e17a4 | |||
| 77c52c1197 | |||
| c65c703e00 | |||
| 7560e802cb | |||
| a5a90a806b | |||
| aa0a271d14 | |||
| 5d4e51c862 | |||
| e6057bb93c | |||
| 714bdd0d7c | |||
| 41f3eb76c1 | |||
| e9c58b3f76 | |||
| 3d6976ff6e | |||
| ee978dff7b | |||
| 0bcf5c5f43 | |||
| 2ceedd6587 | |||
| 2ded1a4a9e | |||
| d551a7267f | |||
| 3ddf6b35c6 | |||
| c0d3c70683 | |||
| 7267433d99 | |||
| 09f3c4d27f | |||
| 3083915bc8 | |||
| 5c72c04d27 | |||
| 826149da3d | |||
| f090be328f | |||
| ecbfcd2b54 | |||
| 2d21b1c542 | |||
| c63438af5e | |||
| 8e47013ae2 | |||
| 92fa0ebcc1 | |||
| f1d23616f1 | |||
| d554f2d801 | |||
| 1fe4608604 | |||
| bea534b352 | |||
| e993c19a34 | |||
| 2deb5d1bc1 | |||
| fb1b0bbcd5 | |||
| 0308dd6c58 | |||
| 860bf7fc7a | |||
| c24024db18 | |||
| 7d330838e0 | |||
| feed3fff0d | |||
| 4ddaafd052 | |||
| 140194a0ad | |||
| 7c6adae87f | |||
| 03deb91a25 | |||
| ea2caa98ae | |||
| dab69b9229 | |||
| 0ffa86ff36 | |||
| 3b08b71472 | |||
| 6c7b1353df | |||
| 9e009d2ded | |||
| dc6a829f0f | |||
| 3a3a4aa9aa | |||
| 99cd22d76b | |||
| 0a920bffe6 | |||
| 55fbc65231 | |||
| f508ac151b | |||
| abec12eed1 | |||
| 9b50fbbe05 | |||
| 9311e7c11d | |||
| 625972da87 | |||
| d3293615b0 | |||
| eef1fcc705 | |||
| ffe8259025 | |||
| bac42b51e1 | |||
| 510ec2990d | |||
| dc06056cc7 | |||
| 103d6e8854 | |||
| 466f1e66cc | |||
| 5de90b20f6 | |||
| 28a26b61ac | |||
| 2182b47666 | |||
| 347c62ec8b | |||
| 736e4c6cae | |||
| 92a07e2acf | |||
| 83af139d01 | |||
| e76e297d22 | |||
| b317368df2 | |||
| 572170b1cc | |||
| 1adeb53a0d | |||
| 3e5811ec11 | |||
| e26a76c8cf | |||
| 1d5755e415 | |||
| 32beec7515 | |||
| feb2250f37 | |||
| da36db39c5 | |||
| fd0fa71f85 | |||
| 04250b38bb | |||
| deb6978572 | |||
| d2a26ead1e | |||
| 9e40f406ee | |||
| 2cdfaf9356 | |||
| 66af399bc0 | |||
| 5fa69dec3e | |||
| 3d5f3303d1 | |||
| 10883e92b1 | |||
| e2d28333ec | |||
| 6d55d4832f | |||
| 46c563873f | |||
| 10c5d3c408 | |||
| 942080c460 | |||
| 6a0869ca0a | |||
| d9992dd2f2 | |||
| c8c9631518 | |||
| 943f29f227 | |||
| 80a069077c | |||
| e344e55e6a | |||
| a69947b69a | |||
| 926f593f50 | |||
| f13734a4e9 | |||
| 2107166926 | |||
| 60e5601690 | |||
| 0957d9ef88 | |||
| eb481c57ec | |||
| e0b1666f5d | |||
| f2afc088af | |||
| 971d59b2c6 | |||
| 83fe58056f | |||
| 5f107922d0 | |||
| a2ea6b7bf7 | |||
| 1e0e728840 | |||
| cea6ed3f95 | |||
| f15705ed47 | |||
| 0b9c13ae4b | |||
| d0c8ae02f0 | |||
| 4eda7401b9 | |||
| 0c2f3c9399 | |||
| 541ecae48c | |||
| 82a551f1d4 | |||
| fd2253135d | |||
| d0820aa47e | |||
| 09c5800f49 | |||
| 52aa2dc6a8 | |||
| 6418ee7735 | |||
| 25aef89b4f | |||
| 77d17f97d3 | |||
| d5c1ec3aea | |||
| 97a7384381 | |||
| 27b7bfccba | |||
| ab15624a93 | |||
| a9c5d8ed69 | |||
| 234b90796e | |||
| 5f018ba22f | |||
| e11cbbcc18 | |||
| ba4d30d8db | |||
| d11431769c | |||
| d1d1bbb3e4 | |||
| 823f30882a | |||
| d36f389469 | |||
| add686a4d5 | |||
| 59be2d8f02 | |||
| aa3db49f65 | |||
| c101cb3d3f | |||
| 63912af43b | |||
| d19263dd79 | |||
| f8c9c59a03 | |||
| 5595f7a954 | |||
| bac4527945 | |||
| 51da8bcb72 | |||
| 8aa6e7e245 | |||
| 5029b35d14 | |||
| 2412027d9b | |||
| ddff5219e5 | |||
| 346bc18b58 | |||
| c256ae3664 | |||
| 2b47358a9e | |||
| e0b041ce44 | |||
| 3f64dee927 | |||
| 67c5ebeabd | |||
| 84bc8e96cc | |||
| 2d30cec7c6 | |||
| 5f5f66178b | |||
| e9fadec0ac | |||
| 10008e51ff | |||
| 3098610d17 | |||
| 7a2566d320 | |||
| 01ba8ca53e | |||
| 2a0a2dccff | |||
| 66a6f32d4c | |||
| 02b3f61953 | |||
| dc08526255 | |||
| 2ca339610b | |||
| ffe65937c1 | |||
| 4b6f03eef5 |
+199
-51
@@ -1,60 +1,208 @@
|
||||
# Bot Configuration
|
||||
# ===============================================
|
||||
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
|
||||
# ===============================================
|
||||
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# настройки реф программы юзернейм без @!
|
||||
BOT_USERNAME=
|
||||
REFERRAL_FIRST_REWARD=100.0
|
||||
REFERRAL_REFERRED_BONUS=100.0
|
||||
REFERRAL_THRESHOLD=200.0
|
||||
REFERRAL_PERCENTAGE=0.2
|
||||
# ===== 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
|
||||
|
||||
# RemnaWave API Configuration
|
||||
REMNAWAVE_URL=https://example.com
|
||||
REMNAWAVE_TOKEN=
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Admin Configuration (comma-separated list of Telegram user IDs)
|
||||
ADMIN_IDS=123445567,12342123
|
||||
# Пароли для Docker (PostgreSQL/Redis)
|
||||
POSTGRES_DB=remnawave_bot
|
||||
POSTGRES_USER=remnawave_user
|
||||
POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# Support Configuration
|
||||
SUPPORT_USERNAME=support
|
||||
# ===== 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
|
||||
|
||||
TRIAL_ENABLED=true # Включить триал
|
||||
TRIAL_DURATION_DAYS=3 # Дней триала
|
||||
TRIAL_TRAFFIC_GB=2 # Лимит трафика у триал подписки
|
||||
TRIAL_SQUAD_UUID= # UUID сквада из панели remnawave /dashboard/management/internal-squads
|
||||
TRIAL_PRICE=0.0 # Оставить 0!
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
|
||||
# Monitor Service Settings (дополнительные настройки)
|
||||
MONITOR_CHECK_INTERVAL=21600 # Промежуток проверики (3600 - будет раз в час проверять и слать уведомления)
|
||||
MONITOR_DAILY_CHECK_HOUR=10 # Ежедневный чек в 10 утра
|
||||
MONITOR_WARNING_DAYS=2 # За сколько начинать до окончания подписки слать уведомления
|
||||
DELETE_EXPIRED_TRIAL_DAYS=1 # Через сколько дней после истечения удалять триал подписки
|
||||
DELETE_EXPIRED_REGULAR_DAYS=7 # Через сколько дней после истечения удалять обычные подписки
|
||||
AUTO_DELETE_ENABLED=true # Включить автоматическое удаление при ежедневной проверке
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
LUCKY_GAME_ENABLED=true # Включить/выключить игру удачи
|
||||
LUCKY_GAME_REWARD=50.0 # Размер награды за выигрыш в рублях
|
||||
LUCKY_GAME_NUMBERS=30 # Общее количество чисел для выбора (1-30)
|
||||
LUCKY_GAME_WINNING_COUNT=25 # Количество выигрышных чисел из общего количества
|
||||
# Максимум устройств достопных к покупке (0 = Нет лимита)
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# ================================
|
||||
# TELEGRAM STARS НАСТРОЙКИ
|
||||
# ================================
|
||||
# Включить/выключить пополнение через Telegram Stars
|
||||
STARS_ENABLED=true
|
||||
# Курсы обмена звезд на рубли
|
||||
# Формат: STARS_{количество_звезд}_RATE={рубли}
|
||||
# 100 звёзд
|
||||
STARS_100_RATE=110
|
||||
# 150 звёзд
|
||||
STARS_150_RATE=165
|
||||
# 250 звёзд
|
||||
STARS_250_RATE=280
|
||||
# 350 звёзд
|
||||
STARS_350_RATE=355
|
||||
# 500 звёзд
|
||||
STARS_500_RATE=550
|
||||
# 750 звёзд
|
||||
STARS_750_RATE=800
|
||||
# 1000 звёзд
|
||||
STARS_1000_RATE=1000
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
# "selectable" - пользователи выбирают пакеты трафика (по умолчанию)
|
||||
# "fixed" - фиксированный лимит трафика для всех подписок
|
||||
TRAFFIC_SELECTION_MODE=selectable
|
||||
|
||||
# Фиксированный лимит трафика в ГБ (используется только в режиме "fixed")
|
||||
# 0 = безлимит
|
||||
FIXED_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ПЕРИОДЫ ПОДПИСКИ =====
|
||||
# Доступные периоды подписки (через запятую)
|
||||
# Возможные значения: 14,30,60,90,180,360
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== ЦЕНЫ (в копейках) =====
|
||||
BASE_SUBSCRIPTION_PRICE=0
|
||||
|
||||
# Цены за периоды
|
||||
PRICE_14_DAYS=7000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_60_DAYS=25900
|
||||
PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
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_REGISTRATION_REWARD=10000
|
||||
REFERRED_USER_REWARD=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
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=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
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
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
name: BedolagaBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Build and Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
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:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Generate security report
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: quickview,compare
|
||||
image: ${{ steps.meta.outputs.tags }}
|
||||
to: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
ignore-unchanged: true
|
||||
only-severities: critical,high
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+30
-2
@@ -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
|
||||
|
||||
+22
-25
@@ -1,46 +1,43 @@
|
||||
# Use Python 3.11 slim image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& apt-get clean \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -r botuser && useradd -r -g botuser botuser
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||
pip install --no-cache-dir -r requirements.txt --prefer-binary
|
||||
# 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 necessary directories
|
||||
RUN mkdir -p /app/logs /app/data && \
|
||||
chown -R botuser:botuser /app
|
||||
# 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 botuser
|
||||
USER app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Expose webhook ports
|
||||
EXPOSE 8081
|
||||
EXPOSE 8082
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import asyncio; import sys; sys.exit(0)"
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
|
||||
# Default command
|
||||
CMD ["python3", "main.py"]
|
||||
# Run the application
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# RemnaWave Bot Docker Management
|
||||
|
||||
.PHONY: help build up down restart logs clean db-backup db-restore
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " build - Build all Docker images"
|
||||
@echo " up - Start all services"
|
||||
@echo " up-min - Start only bot and database (minimal setup)"
|
||||
@echo " up-full - Start all services including nginx and redis"
|
||||
@echo " down - Stop all services"
|
||||
@echo " restart - Restart all services"
|
||||
@echo " logs - Show logs for all services"
|
||||
@echo " logs-bot - Show logs for bot service only"
|
||||
@echo " logs-db - Show logs for database service only"
|
||||
@echo " clean - Remove all containers, networks, and volumes"
|
||||
@echo " db-backup - Create database backup"
|
||||
@echo " db-restore - Restore database from backup"
|
||||
@echo " shell-bot - Open shell in bot container"
|
||||
@echo " shell-db - Open shell in database container"
|
||||
|
||||
# Build all images
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
# Start minimal services (bot + database)
|
||||
up-min: setup-dirs
|
||||
docker compose up -d postgres bot
|
||||
|
||||
# Start all services including optional ones
|
||||
up-full: setup-dirs
|
||||
docker compose --profile with-nginx up -d
|
||||
|
||||
# Start main services (default)
|
||||
up: setup-dirs
|
||||
docker compose up -d postgres redis bot
|
||||
|
||||
# Setup required directories
|
||||
setup-dirs:
|
||||
mkdir -p logs data backups
|
||||
|
||||
# Stop all services
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# Restart all services
|
||||
restart:
|
||||
docker compose restart
|
||||
|
||||
# Show logs for all services
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Show logs for bot only
|
||||
logs-bot:
|
||||
docker compose logs -f bot
|
||||
|
||||
# Show logs for database only
|
||||
logs-db:
|
||||
docker compose logs -f postgres
|
||||
|
||||
# Clean up everything (DANGEROUS - removes all data)
|
||||
clean:
|
||||
@echo "This will remove all containers, networks, and volumes. Are you sure? [y/N]"
|
||||
@read answer && [ "$$answer" = "y" ] || [ "$$answer" = "Y" ]
|
||||
docker compose down -v --remove-orphans
|
||||
docker system prune -f
|
||||
|
||||
# Database backup
|
||||
db-backup:
|
||||
@mkdir -p backups
|
||||
docker compose exec postgres pg_dump -U remnawave_user remnawave_bot > backups/backup_$(shell date +%Y%m%d_%H%M%S).sql
|
||||
@echo "Backup created in backups/ directory"
|
||||
|
||||
# Database restore (use: make db-restore BACKUP=backup_20231201_120000.sql)
|
||||
db-restore:
|
||||
@if [ -z "$(BACKUP)" ]; then echo "Usage: make db-restore BACKUP=backup_file.sql"; exit 1; fi
|
||||
docker compose exec -T postgres psql -U remnawave_user -d remnawave_bot < backups/$(BACKUP)
|
||||
@echo "Database restored from $(BACKUP)"
|
||||
|
||||
# Open shell in bot container
|
||||
shell-bot:
|
||||
docker compose exec bot /bin/bash
|
||||
|
||||
# Open shell in database container
|
||||
shell-db:
|
||||
docker compose exec postgres psql -U remnawave_user -d remnawave_bot
|
||||
|
||||
# Check services status
|
||||
status:
|
||||
docker compose ps
|
||||
|
||||
# View service resource usage
|
||||
stats:
|
||||
docker stats --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
|
||||
-8305
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,201 +0,0 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram import Router
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from translations import t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class APIErrorHandler:
|
||||
|
||||
@staticmethod
|
||||
async def handle_api_error(callback: CallbackQuery, error: Exception,
|
||||
operation: str, user_language: str = 'ru',
|
||||
fallback_keyboard=None) -> bool:
|
||||
error_message = str(error).lower()
|
||||
|
||||
if "timeout" in error_message or "connection" in error_message:
|
||||
text = "⏱ Таймаут подключения к API\n\n"
|
||||
text += "Возможные причины:\n"
|
||||
text += "• Медленный интернет\n"
|
||||
text += "• Перегрузка сервера RemnaWave\n"
|
||||
text += "• Временные проблемы с сетью\n\n"
|
||||
text += "🔄 Попробуйте повторить операцию через несколько секунд"
|
||||
|
||||
elif "401" in error_message or "unauthorized" in error_message:
|
||||
text = "🔐 Ошибка авторизации API\n\n"
|
||||
text += "Токен доступа недействителен или истек.\n"
|
||||
text += "Обратитесь к администратору для обновления токена."
|
||||
|
||||
elif "404" in error_message or "not found" in error_message:
|
||||
text = f"❌ Ресурс не найден\n\n"
|
||||
text += f"Операция: {operation}\n"
|
||||
text += "Возможно, запрашиваемый объект был удален или не существует."
|
||||
|
||||
elif "500" in error_message or "internal server error" in error_message:
|
||||
text = "🔥 Внутренняя ошибка сервера RemnaWave\n\n"
|
||||
text += "Сервер временно недоступен.\n"
|
||||
text += "Попробуйте повторить операцию позже."
|
||||
|
||||
else:
|
||||
text = f"❌ Ошибка API операции: {operation}\n\n"
|
||||
text += f"Детали: {str(error)[:100]}{'...' if len(str(error)) > 100 else ''}\n\n"
|
||||
text += "Обратитесь к администратору если проблема повторяется."
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=fallback_keyboard or error_recovery_keyboard(operation, user_language)
|
||||
)
|
||||
return True
|
||||
except Exception as edit_error:
|
||||
logger.error(f"Failed to edit message with error info: {edit_error}")
|
||||
try:
|
||||
await callback.answer(f"❌ Ошибка: {operation}", show_alert=True)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def safe_api_call(api_method: Callable, *args, **kwargs) -> tuple[bool, Any]:
|
||||
try:
|
||||
result = await api_method(*args, **kwargs)
|
||||
return True, result
|
||||
except Exception as e:
|
||||
logger.error(f"API call failed: {api_method.__name__} - {e}")
|
||||
return False, str(e)
|
||||
|
||||
def create_error_recovery_keyboard(error_context: str, language: str = 'ru'):
|
||||
from keyboards import error_recovery_keyboard
|
||||
return error_recovery_keyboard(error_context, language)
|
||||
|
||||
async def safe_get_nodes(api: RemnaWaveAPI) -> tuple[bool, list]:
|
||||
try:
|
||||
logger.info("Attempting to fetch nodes from API...")
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
if nodes is None:
|
||||
logger.warning("API returned None for nodes")
|
||||
return False, []
|
||||
|
||||
if not isinstance(nodes, list):
|
||||
logger.warning(f"API returned non-list for nodes: {type(nodes)}")
|
||||
return False, []
|
||||
|
||||
logger.info(f"Successfully fetched {len(nodes)} nodes")
|
||||
return True, nodes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching nodes: {e}")
|
||||
return False, []
|
||||
|
||||
async def safe_get_system_users(api: RemnaWaveAPI) -> tuple[bool, list]:
|
||||
try:
|
||||
logger.info("Attempting to fetch system users from API...")
|
||||
users = await api.get_all_system_users_full()
|
||||
|
||||
if users is None:
|
||||
logger.warning("API returned None for users")
|
||||
return False, []
|
||||
|
||||
if not isinstance(users, list):
|
||||
logger.warning(f"API returned non-list for users: {type(users)}")
|
||||
return False, []
|
||||
|
||||
logger.info(f"Successfully fetched {len(users)} users")
|
||||
return True, users
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching system users: {e}")
|
||||
return False, []
|
||||
|
||||
async def safe_restart_nodes(api: RemnaWaveAPI, all_nodes: bool = True, node_id: str = None) -> tuple[bool, str]:
|
||||
try:
|
||||
if all_nodes:
|
||||
logger.info("Attempting to restart all nodes...")
|
||||
result = await api.restart_all_nodes()
|
||||
else:
|
||||
logger.info(f"Attempting to restart node {node_id}...")
|
||||
result = await api.restart_node(node_id)
|
||||
|
||||
if result:
|
||||
message = "Команда перезагрузки отправлена успешно"
|
||||
logger.info(f"Restart command sent successfully")
|
||||
return True, message
|
||||
else:
|
||||
message = "API вернул отрицательный результат"
|
||||
logger.warning("API returned negative result for restart")
|
||||
return False, message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error restarting nodes: {e}")
|
||||
return False, str(e)
|
||||
|
||||
async def check_api_health(api: RemnaWaveAPI) -> Dict[str, Any]:
|
||||
health_info = {
|
||||
'api_available': False,
|
||||
'nodes_accessible': False,
|
||||
'users_accessible': False,
|
||||
'system_stats_accessible': False,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
if api is None:
|
||||
health_info['errors'].append("API instance is None")
|
||||
return health_info
|
||||
|
||||
try:
|
||||
success, nodes = await safe_get_nodes(api)
|
||||
if success:
|
||||
health_info['api_available'] = True
|
||||
health_info['nodes_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch nodes")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"Nodes check failed: {e}")
|
||||
|
||||
try:
|
||||
success, users = await safe_get_system_users(api)
|
||||
if success:
|
||||
health_info['users_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch users")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"Users check failed: {e}")
|
||||
|
||||
try:
|
||||
stats = await api.get_system_stats()
|
||||
if stats:
|
||||
health_info['system_stats_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch system stats")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"System stats check failed: {e}")
|
||||
|
||||
return health_info
|
||||
|
||||
def handle_api_errors(operation_name: str):
|
||||
def decorator(func):
|
||||
async def wrapper(callback: CallbackQuery, user, *args, **kwargs):
|
||||
try:
|
||||
return await func(callback, user, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {func.__name__}: {e}")
|
||||
|
||||
api = kwargs.get('api')
|
||||
fallback_keyboard = None
|
||||
|
||||
if 'nodes' in operation_name.lower():
|
||||
from keyboards import admin_system_keyboard
|
||||
fallback_keyboard = admin_system_keyboard(user.language)
|
||||
elif 'users' in operation_name.lower():
|
||||
from keyboards import system_users_keyboard
|
||||
fallback_keyboard = system_users_keyboard(user.language)
|
||||
|
||||
await APIErrorHandler.handle_api_error(
|
||||
callback, e, operation_name, user.language, fallback_keyboard
|
||||
)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"ru": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"ru": "App Store [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"ru": "Скачать APK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Откройте приложение и подключитесь к серверу"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pc": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
|
||||
"buttonText": {
|
||||
"en": "Mac Os",
|
||||
"ru": "Mac Os"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"ru": "Windows"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tv": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import logging
|
||||
from aiogram import Bot, Dispatcher, types
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, common
|
||||
)
|
||||
from app.handlers.admin import (
|
||||
main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
|
||||
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,
|
||||
maintenance as admin_maintenance
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def debug_callback_handler(callback: types.CallbackQuery):
|
||||
logger.info(f"🔍 DEBUG CALLBACK:")
|
||||
logger.info(f" - Data: {callback.data}")
|
||||
logger.info(f" - User: {callback.from_user.id}")
|
||||
logger.info(f" - Username: {callback.from_user.username}")
|
||||
|
||||
|
||||
async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
try:
|
||||
await cache.connect()
|
||||
logger.info("Кеш инициализирован")
|
||||
except Exception as e:
|
||||
logger.warning(f"Кеш не инициализирован: {e}")
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
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")
|
||||
except Exception as e:
|
||||
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)
|
||||
balance.register_handlers(dp)
|
||||
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)
|
||||
admin_servers.register_handlers(dp)
|
||||
admin_promocodes.register_handlers(dp)
|
||||
admin_messages.register_handlers(dp)
|
||||
admin_monitoring.register_handlers(dp)
|
||||
admin_referrals.register_handlers(dp)
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
|
||||
common.register_handlers(dp)
|
||||
|
||||
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}")
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
import os
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
BOT_TOKEN: str
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
|
||||
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
|
||||
NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
|
||||
MONITORING_LOGS_RETENTION_DAYS: int = 30
|
||||
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
|
||||
PRICE_90_DAYS: int = 269000
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
|
||||
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
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
|
||||
|
||||
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
|
||||
TRIBUTE_WEBHOOK_SECRET: Optional[str] = None
|
||||
TRIBUTE_DONATE_LINK: Optional[str] = None
|
||||
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 = ""
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/bot.log"
|
||||
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
|
||||
APP_CONFIG_PATH: str = "app-config.json"
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
APP_CONFIG_CACHE_TTL: int = 3600
|
||||
|
||||
@field_validator('LOG_FILE', mode='before')
|
||||
@classmethod
|
||||
def ensure_log_dir(cls, v):
|
||||
log_path = Path(v)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return str(log_path)
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
return user_id in self.get_admin_ids()
|
||||
|
||||
def get_admin_ids(self) -> List[int]:
|
||||
try:
|
||||
admin_ids = self.ADMIN_IDS
|
||||
|
||||
if isinstance(admin_ids, str):
|
||||
if not admin_ids.strip():
|
||||
return []
|
||||
return [int(x.strip()) for x in admin_ids.split(',') if x.strip()]
|
||||
|
||||
return []
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
def get_autopay_warning_days(self) -> List[int]:
|
||||
try:
|
||||
days = self.AUTOPAY_WARNING_DAYS
|
||||
if isinstance(days, str):
|
||||
if not days.strip():
|
||||
return [3, 1]
|
||||
return [int(x.strip()) for x in days.split(',') if x.strip()]
|
||||
return [3, 1]
|
||||
except (ValueError, AttributeError):
|
||||
return [3, 1]
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
if isinstance(langs, str):
|
||||
if not langs.strip():
|
||||
return ["ru", "en"]
|
||||
return [x.strip() for x in langs.split(',') if x.strip()]
|
||||
return ["ru", "en"]
|
||||
except AttributeError:
|
||||
return ["ru", "en"]
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks / 100
|
||||
return f"{rubles:.2f} ₽"
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
|
||||
def rubles_to_kopeks(self, rubles: float) -> int:
|
||||
return int(rubles * 100)
|
||||
|
||||
def get_trial_warning_hours(self) -> int:
|
||||
return self.TRIAL_WARNING_HOURS
|
||||
|
||||
def is_notifications_enabled(self) -> bool:
|
||||
return self.ENABLE_NOTIFICATIONS
|
||||
|
||||
def get_app_config_path(self) -> str:
|
||||
if os.path.isabs(self.APP_CONFIG_PATH):
|
||||
return self.APP_CONFIG_PATH
|
||||
|
||||
project_root = Path(__file__).parent.parent
|
||||
return str(project_root / self.APP_CONFIG_PATH)
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
def get_app_config_cache_ttl(self) -> int:
|
||||
return self.APP_CONFIG_CACHE_TTL
|
||||
|
||||
def is_traffic_selectable(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
|
||||
|
||||
def is_traffic_fixed(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
|
||||
|
||||
def get_fixed_traffic_limit(self) -> int:
|
||||
return self.FIXED_TRAFFIC_LIMIT_GB
|
||||
|
||||
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_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"
|
||||
}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
PERIOD_PRICES = {
|
||||
14: settings.PRICE_14_DAYS,
|
||||
30: settings.PRICE_30_DAYS,
|
||||
60: settings.PRICE_60_DAYS,
|
||||
90: settings.PRICE_90_DAYS,
|
||||
180: settings.PRICE_180_DAYS,
|
||||
360: settings.PRICE_360_DAYS,
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,250 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
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, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(selectinload(PromoCode.uses))
|
||||
.where(PromoCode.code == code.upper())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_promocode(
|
||||
db: AsyncSession,
|
||||
code: str,
|
||||
type: PromoCodeType,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_days: int = 0,
|
||||
max_uses: int = 1,
|
||||
valid_until: Optional[datetime] = None,
|
||||
created_by: Optional[int] = None
|
||||
) -> PromoCode:
|
||||
|
||||
promocode = PromoCode(
|
||||
code=code.upper(),
|
||||
type=type.value,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks,
|
||||
subscription_days=subscription_days,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
db.add(promocode)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
return promocode
|
||||
|
||||
|
||||
async def use_promocode(
|
||||
db: AsyncSession,
|
||||
promocode_id: int,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
return False
|
||||
|
||||
usage = PromoCodeUse(
|
||||
promocode_id=promocode_id,
|
||||
user_id=user_id
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
promocode.current_uses += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Промокод {promocode.code} использован пользователем {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка использования промокода: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def check_user_promocode_usage(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promocode_id: int
|
||||
) -> bool:
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(
|
||||
and_(
|
||||
PromoCodeUse.user_id == user_id,
|
||||
PromoCodeUse.promocode_id == promocode_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
|
||||
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
|
||||
promocode_use = PromoCodeUse(
|
||||
promocode_id=promocode_id,
|
||||
user_id=user_id,
|
||||
used_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(promocode_use)
|
||||
await db.commit()
|
||||
await db.refresh(promocode_use)
|
||||
|
||||
logger.info(f"📝 Записано использование промокода {promocode_id} пользователем {user_id}")
|
||||
return promocode_use
|
||||
|
||||
|
||||
async def get_promocode_use_by_user_and_code(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promocode_id: int
|
||||
) -> Optional[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(
|
||||
and_(
|
||||
PromoCodeUse.user_id == user_id,
|
||||
PromoCodeUse.promocode_id == promocode_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_promocodes(db: AsyncSession, user_id: int) -> List[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse)
|
||||
.where(PromoCodeUse.user_id == user_id)
|
||||
.order_by(PromoCodeUse.used_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
|
||||
async def get_promocodes_list(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[PromoCode]:
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.uses))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_promocodes_count(
|
||||
db: AsyncSession,
|
||||
is_active: Optional[bool] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.count(PromoCode.id))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def update_promocode(
|
||||
db: AsyncSession,
|
||||
promocode: PromoCode,
|
||||
**kwargs
|
||||
) -> PromoCode:
|
||||
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(promocode, field):
|
||||
setattr(promocode, field, value)
|
||||
|
||||
promocode.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
return 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()
|
||||
|
||||
logger.info(f"🗑️ Удален промокод: {promocode.code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления промокода: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
|
||||
total_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id))
|
||||
.where(PromoCodeUse.promocode_id == promocode_id)
|
||||
)
|
||||
total_uses = total_uses_result.scalar()
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id))
|
||||
.where(
|
||||
and_(
|
||||
PromoCodeUse.promocode_id == promocode_id,
|
||||
PromoCodeUse.used_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
today_uses = today_uses_result.scalar()
|
||||
|
||||
recent_uses_result = await db.execute(
|
||||
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_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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ReferralEarning, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_referral_earning(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
referral_id: int,
|
||||
amount_kopeks: int,
|
||||
reason: str,
|
||||
referral_transaction_id: Optional[int] = None
|
||||
) -> ReferralEarning:
|
||||
|
||||
earning = ReferralEarning(
|
||||
user_id=user_id,
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
reason=reason,
|
||||
referral_transaction_id=referral_transaction_id
|
||||
)
|
||||
|
||||
db.add(earning)
|
||||
await db.commit()
|
||||
await db.refresh(earning)
|
||||
|
||||
logger.info(f"💰 Создан реферальный заработок: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return earning
|
||||
|
||||
|
||||
async def get_referral_earnings_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[ReferralEarning]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.options(
|
||||
selectinload(ReferralEarning.referral),
|
||||
selectinload(ReferralEarning.referral_transaction)
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_referral_earnings_by_referral(
|
||||
db: AsyncSession,
|
||||
referral_id: int
|
||||
) -> List[ReferralEarning]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.where(ReferralEarning.referral_id == referral_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_referral_earnings_sum(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == user_id
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.where(ReferralEarning.created_at >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.where(ReferralEarning.created_at <= end_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
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))
|
||||
)
|
||||
users_with_referrals = users_with_referrals_result.scalar()
|
||||
|
||||
active_referrers_result = await db.execute(
|
||||
select(func.count(func.distinct(User.referred_by_id)))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
active_referrers = active_referrers_result.scalar()
|
||||
|
||||
referral_paid_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
)
|
||||
referral_paid = referral_paid_result.scalar()
|
||||
|
||||
from app.database.models import Transaction, TransactionType
|
||||
transaction_paid_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
|
||||
)
|
||||
transaction_paid = transaction_paid_result.scalar()
|
||||
|
||||
total_paid = referral_paid + transaction_paid
|
||||
|
||||
top_referrers_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
|
||||
)
|
||||
)
|
||||
.group_by(User.referred_by_id)
|
||||
.order_by(func.count(User.id).desc())
|
||||
.limit(5)
|
||||
)
|
||||
top_referrers_raw = top_referrers_result.all()
|
||||
|
||||
top_referrers = []
|
||||
for row in top_referrers_raw:
|
||||
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)
|
||||
)
|
||||
user = user_result.first()
|
||||
|
||||
if user:
|
||||
display_name = ""
|
||||
if user.first_name:
|
||||
display_name = user.first_name
|
||||
if user.last_name:
|
||||
display_name += f" {user.last_name}"
|
||||
elif user.username:
|
||||
display_name = f"@{user.username}"
|
||||
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,
|
||||
"display_name": display_name,
|
||||
"username": user.username,
|
||||
"total_earned_kopeks": total_earned,
|
||||
"referrals_count": row.referrals_count
|
||||
})
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
today_referral_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= today)
|
||||
)
|
||||
today_transaction_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
today_earnings = today_referral_earnings.scalar() + today_transaction_earnings.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_referral_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= week_ago)
|
||||
)
|
||||
week_transaction_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= week_ago
|
||||
)
|
||||
)
|
||||
)
|
||||
week_earnings = week_referral_earnings.scalar() + week_transaction_earnings.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_referral_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= month_ago)
|
||||
)
|
||||
month_transaction_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= month_ago
|
||||
)
|
||||
)
|
||||
)
|
||||
month_earnings = month_referral_earnings.scalar() + month_transaction_earnings.scalar()
|
||||
|
||||
return {
|
||||
"users_with_referrals": users_with_referrals,
|
||||
"active_referrers": active_referrers,
|
||||
"total_paid_kopeks": total_paid,
|
||||
"today_earnings_kopeks": today_earnings,
|
||||
"week_earnings_kopeks": week_earnings,
|
||||
"month_earnings_kopeks": month_earnings,
|
||||
"top_referrers": top_referrers
|
||||
}
|
||||
|
||||
|
||||
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
invited_count_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.referred_by_id == user_id)
|
||||
)
|
||||
invited_count = invited_count_result.scalar()
|
||||
|
||||
total_earned = await get_referral_earnings_sum(db, user_id)
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
|
||||
|
||||
active_referrals_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.join(User.subscription)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
User.subscription.has()
|
||||
)
|
||||
)
|
||||
)
|
||||
active_referrals = active_referrals_result.scalar()
|
||||
|
||||
return {
|
||||
"invited_count": invited_count,
|
||||
"active_referrals": active_referrals,
|
||||
"total_earned_kopeks": total_earned,
|
||||
"month_earned_kopeks": month_earned
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.models import ServiceRule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_rules_by_language(db: AsyncSession, language: str = "ru") -> Optional[ServiceRule]:
|
||||
result = await db.execute(
|
||||
select(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.order_by(ServiceRule.order, ServiceRule.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_or_update_rules(
|
||||
db: AsyncSession,
|
||||
content: str,
|
||||
language: str = "ru",
|
||||
title: str = "Правила сервиса"
|
||||
) -> ServiceRule:
|
||||
|
||||
existing_rules_result = await db.execute(
|
||||
select(ServiceRule).where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
)
|
||||
existing_rules = existing_rules_result.scalars().all()
|
||||
|
||||
for rule in existing_rules:
|
||||
rule.is_active = False
|
||||
rule.updated_at = datetime.utcnow()
|
||||
|
||||
new_rules = ServiceRule(
|
||||
title=title,
|
||||
content=content,
|
||||
language=language,
|
||||
is_active=True,
|
||||
order=0
|
||||
)
|
||||
|
||||
db.add(new_rules)
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f"✅ Правила для языка {language} обновлены")
|
||||
return new_rules
|
||||
|
||||
|
||||
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
|
||||
rules = await get_rules_by_language(db, language)
|
||||
|
||||
if rules:
|
||||
return rules.content
|
||||
else:
|
||||
return """
|
||||
🔒 <b>Правила использования сервиса</b>
|
||||
|
||||
1. Сервис предоставляется "как есть" без каких-либо гарантий.
|
||||
|
||||
2. Запрещается использование сервиса для незаконных действий.
|
||||
|
||||
3. Администрация оставляет за собой право заблокировать доступ пользователя при нарушении правил.
|
||||
|
||||
4. Возврат средств осуществляется в соответствии с политикой возврата.
|
||||
|
||||
5. Пользователь несет полную ответственность за безопасность своего аккаунта.
|
||||
|
||||
6. При возникновении вопросов обращайтесь в техническую поддержку.
|
||||
|
||||
Используя сервис, вы соглашаетесь с данными правилами.
|
||||
"""
|
||||
@@ -0,0 +1,355 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, and_, func, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad, SubscriptionServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_server_squad(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str,
|
||||
display_name: str,
|
||||
original_name: str = None,
|
||||
country_code: str = None,
|
||||
price_kopeks: int = 0,
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True
|
||||
) -> ServerSquad:
|
||||
|
||||
server_squad = ServerSquad(
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=display_name,
|
||||
original_name=original_name,
|
||||
country_code=country_code,
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available
|
||||
)
|
||||
|
||||
db.add(server_squad)
|
||||
await db.commit()
|
||||
await db.refresh(server_squad)
|
||||
|
||||
logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})")
|
||||
return server_squad
|
||||
|
||||
|
||||
async def get_server_squad_by_uuid(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_server_squad_by_id(
|
||||
db: AsyncSession,
|
||||
server_id: int
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.id == server_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_server_squads(
|
||||
db: AsyncSession,
|
||||
available_only: bool = False,
|
||||
page: int = 1,
|
||||
limit: int = 50
|
||||
) -> Tuple[List[ServerSquad], int]:
|
||||
|
||||
query = select(ServerSquad)
|
||||
|
||||
if available_only:
|
||||
query = query.where(ServerSquad.is_available == True)
|
||||
|
||||
count_query = select(func.count(ServerSquad.id))
|
||||
if available_only:
|
||||
count_query = count_query.where(ServerSquad.is_available == True)
|
||||
|
||||
count_result = await db.execute(count_query)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
offset = (page - 1) * limit
|
||||
query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
servers = result.scalars().all()
|
||||
|
||||
return servers, total_count
|
||||
|
||||
|
||||
async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad)
|
||||
.where(ServerSquad.is_available == True)
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_server_squad(
|
||||
db: AsyncSession,
|
||||
server_id: int,
|
||||
**updates
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order'
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
|
||||
if not filtered_updates:
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(**filtered_updates)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return await get_server_squad_by_id(db, server_id)
|
||||
|
||||
|
||||
async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
|
||||
|
||||
connections_result = await db.execute(
|
||||
select(func.count(SubscriptionServer.id))
|
||||
.where(SubscriptionServer.server_squad_id == server_id)
|
||||
)
|
||||
connections_count = connections_result.scalar()
|
||||
|
||||
if connections_count > 0:
|
||||
logger.warning(f"❌ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
|
||||
return False
|
||||
|
||||
await db.execute(
|
||||
delete(ServerSquad).where(ServerSquad.id == server_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🗑️ Удален сервер (ID: {server_id})")
|
||||
return True
|
||||
|
||||
|
||||
async def sync_with_remnawave(
|
||||
db: AsyncSession,
|
||||
remnawave_squads: List[dict]
|
||||
) -> Tuple[int, int, int]:
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
disabled = 0
|
||||
|
||||
existing_servers = {}
|
||||
result = await db.execute(select(ServerSquad))
|
||||
for server in result.scalars().all():
|
||||
existing_servers[server.squad_uuid] = server
|
||||
|
||||
remnawave_uuids = {squad['uuid'] for squad in remnawave_squads}
|
||||
|
||||
for squad in remnawave_squads:
|
||||
squad_uuid = squad['uuid']
|
||||
original_name = squad.get('name', f'Squad {squad_uuid[:8]}')
|
||||
|
||||
if squad_uuid in existing_servers:
|
||||
server = existing_servers[squad_uuid]
|
||||
if server.original_name != original_name:
|
||||
server.original_name = original_name
|
||||
updated += 1
|
||||
else:
|
||||
await create_server_squad(
|
||||
db=db,
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=_generate_display_name(original_name),
|
||||
original_name=original_name,
|
||||
country_code=_extract_country_code(original_name),
|
||||
price_kopeks=1000,
|
||||
is_available=False
|
||||
)
|
||||
created += 1
|
||||
|
||||
for uuid, server in existing_servers.items():
|
||||
if uuid not in remnawave_uuids and server.is_available:
|
||||
server.is_available = False
|
||||
disabled += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
|
||||
return created, updated, disabled
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
country_names = {
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'US': '🇺🇸 США',
|
||||
'FR': '🇫🇷 Франция',
|
||||
'GB': '🇬🇧 Великобритания',
|
||||
'IT': '🇮🇹 Италия',
|
||||
'ES': '🇪🇸 Испания',
|
||||
'CA': '🇨🇦 Канада',
|
||||
'JP': '🇯🇵 Япония',
|
||||
'SG': '🇸🇬 Сингапур',
|
||||
'AU': '🇦🇺 Австралия',
|
||||
}
|
||||
|
||||
name_upper = original_name.upper()
|
||||
for code, display_name in country_names.items():
|
||||
if code in name_upper:
|
||||
return display_name
|
||||
|
||||
return f"🌍 {original_name}"
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> Optional[str]:
|
||||
|
||||
codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU']
|
||||
name_upper = original_name.upper()
|
||||
|
||||
for code in codes:
|
||||
if code in name_upper:
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
available_result = await db.execute(
|
||||
select(func.count(ServerSquad.id))
|
||||
.where(ServerSquad.is_available == True)
|
||||
)
|
||||
available_servers = available_result.scalar()
|
||||
|
||||
with_connections_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionServer.server_squad_id)))
|
||||
)
|
||||
servers_with_connections = with_connections_result.scalar()
|
||||
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0))
|
||||
)
|
||||
total_revenue_kopeks = revenue_result.scalar()
|
||||
|
||||
return {
|
||||
'total_servers': total_servers,
|
||||
'available_servers': available_servers,
|
||||
'unavailable_servers': total_servers - available_servers,
|
||||
'servers_with_connections': servers_with_connections,
|
||||
'total_revenue_kopeks': total_revenue_kopeks,
|
||||
'total_revenue_rubles': total_revenue_kopeks / 100
|
||||
}
|
||||
|
||||
async def add_user_to_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=ServerSquad.current_users + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка увеличения счетчика пользователей: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def remove_user_from_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка уменьшения счетчика пользователей: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_server_ids_by_uuids(
|
||||
db: AsyncSession,
|
||||
squad_uuids: List[str]
|
||||
) -> List[int]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.id)
|
||||
.where(ServerSquad.squad_uuid.in_(squad_uuids))
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(
|
||||
ServerSquad.id,
|
||||
ServerSquad.squad_uuid,
|
||||
func.count(SubscriptionServer.id).label('actual_users')
|
||||
)
|
||||
.outerjoin(SubscriptionServer, ServerSquad.id == SubscriptionServer.server_squad_id)
|
||||
.join(Subscription, SubscriptionServer.subscription_id == Subscription.id)
|
||||
.where(Subscription.status == 'active')
|
||||
.group_by(ServerSquad.id, ServerSquad.squad_uuid)
|
||||
)
|
||||
|
||||
updated_count = 0
|
||||
for server_id, squad_uuid, actual_users in result.fetchall():
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=actual_users)
|
||||
)
|
||||
updated_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Синхронизированы счетчики для {updated_count} серверов")
|
||||
return updated_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Squad
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Optional[Squad]:
|
||||
result = await db.execute(
|
||||
select(Squad).where(Squad.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_available_squads(db: AsyncSession) -> List[Squad]:
|
||||
result = await db.execute(
|
||||
select(Squad).where(Squad.is_available == True)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def create_squad(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
name: str,
|
||||
country_code: str = None,
|
||||
price_kopeks: int = 0,
|
||||
description: str = None
|
||||
) -> Squad:
|
||||
squad = Squad(
|
||||
uuid=uuid,
|
||||
name=name,
|
||||
country_code=country_code,
|
||||
price_kopeks=price_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
db.add(squad)
|
||||
await db.commit()
|
||||
await db.refresh(squad)
|
||||
|
||||
logger.info(f"✅ Создан сквад: {name}")
|
||||
return squad
|
||||
|
||||
|
||||
async def update_squad(
|
||||
db: AsyncSession,
|
||||
squad: Squad,
|
||||
**kwargs
|
||||
) -> Squad:
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(squad, field):
|
||||
setattr(squad, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(squad)
|
||||
|
||||
return squad
|
||||
@@ -0,0 +1,606 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if subscription:
|
||||
subscription = await check_and_update_subscription_status(db, subscription)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_trial_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: int = None,
|
||||
squad_uuid: str = None
|
||||
) -> Subscription:
|
||||
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=True,
|
||||
start_date=datetime.utcnow(),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else []
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=False,
|
||||
start_date=datetime.utcnow(),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or []
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def extend_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
days: int
|
||||
) -> Subscription:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
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
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
|
||||
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def add_subscription_traffic(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
gb: int
|
||||
) -> Subscription:
|
||||
|
||||
subscription.add_traffic(gb)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика")
|
||||
return subscription
|
||||
|
||||
|
||||
async def add_subscription_devices(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
devices: int
|
||||
) -> Subscription:
|
||||
|
||||
subscription.device_limit += devices
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств")
|
||||
return subscription
|
||||
|
||||
|
||||
async def add_subscription_squad(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
squad_uuid: str
|
||||
) -> Subscription:
|
||||
|
||||
if squad_uuid not in subscription.connected_squads:
|
||||
subscription.connected_squads = subscription.connected_squads + [squad_uuid]
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}")
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def remove_subscription_squad(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
squad_uuid: str
|
||||
) -> Subscription:
|
||||
|
||||
if squad_uuid in subscription.connected_squads:
|
||||
squads = subscription.connected_squads.copy()
|
||||
squads.remove(squad_uuid)
|
||||
subscription.connected_squads = squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}")
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def update_subscription_autopay(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
enabled: bool,
|
||||
days_before: int = 3
|
||||
) -> Subscription:
|
||||
|
||||
subscription.autopay_enabled = enabled
|
||||
subscription.autopay_days_before = days_before
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
status = "включен" if enabled else "выключен"
|
||||
logger.info(f"💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def deactivate_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Subscription:
|
||||
|
||||
subscription.status = SubscriptionStatus.DISABLED.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"❌ Подписка пользователя {subscription.user_id} деактивирована")
|
||||
return subscription
|
||||
|
||||
|
||||
async def get_expiring_subscriptions(
|
||||
db: AsyncSession,
|
||||
days_before: int = 3
|
||||
) -> List[Subscription]:
|
||||
|
||||
threshold_date = datetime.utcnow() + timedelta(days=days_before)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date <= threshold_date,
|
||||
Subscription.end_date > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date <= datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.autopay_enabled == True,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
all_autopay_subscriptions = result.scalars().all()
|
||||
|
||||
ready_for_autopay = []
|
||||
for subscription in all_autopay_subscriptions:
|
||||
days_until_expiry = (subscription.end_date - current_time).days
|
||||
|
||||
if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time:
|
||||
ready_for_autopay.append(subscription)
|
||||
|
||||
return ready_for_autopay
|
||||
|
||||
|
||||
async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_result = await db.execute(select(func.count(Subscription.id)))
|
||||
total_subscriptions = total_result.scalar()
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(Subscription.status == SubscriptionStatus.ACTIVE.value)
|
||||
)
|
||||
active_subscriptions = active_result.scalar()
|
||||
|
||||
trial_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_trial == True,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
trial_subscriptions = trial_result.scalar()
|
||||
|
||||
paid_subscriptions = active_subscriptions - trial_subscriptions
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.created_at >= today,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_today = today_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.created_at >= week_ago,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_week = week_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.created_at >= month_ago,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_month = month_result.scalar()
|
||||
|
||||
return {
|
||||
"total_subscriptions": total_subscriptions,
|
||||
"active_subscriptions": active_subscriptions,
|
||||
"trial_subscriptions": trial_subscriptions,
|
||||
"paid_subscriptions": paid_subscriptions,
|
||||
"purchased_today": purchased_today,
|
||||
"purchased_week": purchased_week,
|
||||
"purchased_month": purchased_month
|
||||
}
|
||||
|
||||
|
||||
async def update_subscription_usage(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
used_gb: float
|
||||
) -> Subscription:
|
||||
subscription.traffic_used_gb = used_gb
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
return subscription
|
||||
|
||||
async def get_all_subscriptions(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 10
|
||||
) -> Tuple[List[Subscription], int]:
|
||||
count_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
return subscriptions, total_count
|
||||
|
||||
async def add_subscription_servers(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
server_squad_ids: List[int],
|
||||
paid_prices: List[int] = None
|
||||
) -> Subscription:
|
||||
|
||||
if paid_prices is None:
|
||||
paid_prices = [0] * len(server_squad_ids)
|
||||
|
||||
for i, server_id in enumerate(server_squad_ids):
|
||||
subscription_server = SubscriptionServer(
|
||||
subscription_id=subscription.id,
|
||||
server_squad_id=server_id,
|
||||
paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0
|
||||
)
|
||||
db.add(subscription_server)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов")
|
||||
return subscription
|
||||
|
||||
async def get_subscription_server_ids(
|
||||
db: AsyncSession,
|
||||
subscription_id: int
|
||||
) -> List[int]:
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer.server_squad_id)
|
||||
.where(SubscriptionServer.subscription_id == subscription_id)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_subscription_servers(
|
||||
db: AsyncSession,
|
||||
subscription_id: int
|
||||
) -> List[dict]:
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer, ServerSquad)
|
||||
.join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id)
|
||||
.where(SubscriptionServer.subscription_id == subscription_id)
|
||||
)
|
||||
|
||||
servers_info = []
|
||||
for sub_server, server_squad in result.fetchall():
|
||||
servers_info.append({
|
||||
'server_id': server_squad.id,
|
||||
'squad_uuid': server_squad.squad_uuid,
|
||||
'display_name': server_squad.display_name,
|
||||
'country_code': server_squad.country_code,
|
||||
'paid_price_kopeks': sub_server.paid_price_kopeks,
|
||||
'connected_at': sub_server.connected_at,
|
||||
'is_available': server_squad.is_available
|
||||
})
|
||||
|
||||
return servers_info
|
||||
|
||||
async def remove_subscription_servers(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
server_squad_ids: List[int]
|
||||
) -> bool:
|
||||
try:
|
||||
from app.database.models import SubscriptionServer
|
||||
from sqlalchemy import delete
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer)
|
||||
.where(
|
||||
SubscriptionServer.subscription_id == subscription_id,
|
||||
SubscriptionServer.server_squad_id.in_(server_squad_ids)
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления серверов из подписки: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES, TRAFFIC_PRICES, settings
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_cost = sum(server_info['paid_price_kopeks'] for server_info in servers_info)
|
||||
|
||||
subscription = await db.get(Subscription, subscription_id)
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
traffic_cost = 0
|
||||
if subscription.traffic_limit_gb > 0:
|
||||
traffic_cost = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
|
||||
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_cost = base_price + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней:")
|
||||
logger.info(f" 📅 Период: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_cost/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_cost/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_cost/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета стоимости продления: {e}")
|
||||
from app.config import PERIOD_PRICES
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
async def expire_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Subscription:
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая")
|
||||
return subscription
|
||||
|
||||
|
||||
async def check_and_update_subscription_status(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Subscription:
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
|
||||
|
||||
return subscription
|
||||
|
||||
async def create_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: str = "trial",
|
||||
is_trial: bool = True,
|
||||
end_date: datetime = None,
|
||||
traffic_limit_gb: int = 10,
|
||||
traffic_used_gb: float = 0.0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = ""
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.utcnow() + timedelta(days=3)
|
||||
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
is_trial=is_trial,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"✅ Создана подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
@@ -0,0 +1,358 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, or_, func, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import Transaction, TransactionType, PaymentMethod, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_transaction(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
type: TransactionType,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
external_id: Optional[str] = None,
|
||||
is_completed: bool = True
|
||||
) -> Transaction:
|
||||
|
||||
transaction = Transaction(
|
||||
user_id=user_id,
|
||||
type=type.value,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method.value if payment_method else None,
|
||||
external_id=external_id,
|
||||
is_completed=is_completed,
|
||||
completed_at=datetime.utcnow() if is_completed else None
|
||||
)
|
||||
|
||||
db.add(transaction)
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return transaction
|
||||
|
||||
|
||||
async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Optional[Transaction]:
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(Transaction.id == transaction_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_transaction_by_external_id(
|
||||
db: AsyncSession,
|
||||
external_id: str,
|
||||
payment_method: PaymentMethod
|
||||
) -> Optional[Transaction]:
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.external_id == external_id,
|
||||
Transaction.payment_method == payment_method.value
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_transactions(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Transaction]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.where(Transaction.user_id == user_id)
|
||||
.order_by(Transaction.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_transactions_count(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
transaction_type: Optional[TransactionType] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.count(Transaction.id)).where(Transaction.user_id == user_id)
|
||||
|
||||
if transaction_type:
|
||||
query = query.where(Transaction.type == transaction_type.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
|
||||
|
||||
transaction.is_completed = True
|
||||
transaction.completed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"✅ Транзакция {transaction.id} завершена")
|
||||
return transaction
|
||||
|
||||
|
||||
async def get_pending_transactions(db: AsyncSession) -> List[Transaction]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(Transaction.is_completed == False)
|
||||
.order_by(Transaction.created_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_transactions_statistics(
|
||||
db: AsyncSession,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> dict:
|
||||
|
||||
if not start_date:
|
||||
start_date = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if not end_date:
|
||||
end_date = datetime.utcnow()
|
||||
|
||||
income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
total_income = income_result.scalar()
|
||||
|
||||
expenses_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.WITHDRAWAL.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
total_expenses = expenses_result.scalar()
|
||||
|
||||
subscription_income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
subscription_income = subscription_income_result.scalar()
|
||||
|
||||
transactions_count_result = await db.execute(
|
||||
select(
|
||||
Transaction.type,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.type)
|
||||
)
|
||||
transactions_by_type = {row.type: {"count": row.count, "amount": row.total_amount}
|
||||
for row in transactions_count_result}
|
||||
|
||||
payment_methods_result = await db.execute(
|
||||
select(
|
||||
Transaction.payment_method,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.payment_method)
|
||||
)
|
||||
payment_methods = {row.payment_method: {"count": row.count, "amount": row.total_amount}
|
||||
for row in payment_methods_result}
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(Transaction.id))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
transactions_today = today_result.scalar()
|
||||
|
||||
today_income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
income_today = today_income_result.scalar()
|
||||
|
||||
return {
|
||||
"period": {
|
||||
"start_date": start_date,
|
||||
"end_date": end_date
|
||||
},
|
||||
"totals": {
|
||||
"income_kopeks": total_income,
|
||||
"expenses_kopeks": total_expenses,
|
||||
"profit_kopeks": total_income - total_expenses,
|
||||
"subscription_income_kopeks": subscription_income
|
||||
},
|
||||
"today": {
|
||||
"transactions_count": transactions_today,
|
||||
"income_kopeks": income_today
|
||||
},
|
||||
"by_type": transactions_by_type,
|
||||
"by_payment_method": payment_methods
|
||||
}
|
||||
|
||||
|
||||
async def get_revenue_by_period(
|
||||
db: AsyncSession,
|
||||
days: int = 30
|
||||
) -> List[dict]:
|
||||
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date
|
||||
)
|
||||
)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
.order_by(func.date(Transaction.created_at))
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,358 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_referral_code() -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
code_suffix = ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||
return f"ref{code_suffix}"
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
max_attempts = 10
|
||||
|
||||
for _ in range(max_attempts):
|
||||
code = generate_referral_code()
|
||||
existing_user = await get_user_by_referral_code(db, code)
|
||||
if not existing_user:
|
||||
return code
|
||||
|
||||
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language: str = "ru",
|
||||
referred_by_id: int = None,
|
||||
referral_code: str = None
|
||||
) -> User:
|
||||
|
||||
if not referral_code:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def update_user(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
**kwargs
|
||||
) -> User:
|
||||
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def add_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += 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} изменен: {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 = "Пополнение баланса"
|
||||
) -> 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,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
||||
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||
logger.error(f" 📝 Описание: {description}")
|
||||
|
||||
if user.balance_kopeks < amount_kopeks:
|
||||
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
|
||||
return False
|
||||
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ ОШИБКА СПИСАНИЯ: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_users_list(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[UserStatus] = None
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
conditions = [
|
||||
User.first_name.ilike(search_term),
|
||||
User.last_name.ilike(search_term),
|
||||
User.username.ilike(search_term)
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
db: AsyncSession,
|
||||
status: Optional[UserStatus] = None,
|
||||
search: Optional[str] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.count(User.id))
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
conditions = [
|
||||
User.first_name.ilike(search_term),
|
||||
User.last_name.ilike(search_term),
|
||||
User.username.ilike(search_term)
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user: User) -> bool:
|
||||
user.status = UserStatus.DELETED.value
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"🗑️ Пользователь {user.telegram_id} помечен как удаленный")
|
||||
return True
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_result = await db.execute(select(func.count(User.id)))
|
||||
total_users = total_result.scalar()
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
active_users = active_result.scalar()
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= today,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
new_today = today_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= week_ago,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
new_week = week_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= month_ago,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
new_month = month_result.scalar()
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"blocked_users": total_users - active_users,
|
||||
"new_today": new_today,
|
||||
"new_week": new_week,
|
||||
"new_month": new_month
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
poolclass=NullPool,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
|
||||
async def close_db():
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
@@ -0,0 +1,556 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text,
|
||||
ForeignKey, Float, JSON, BigInteger
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
BLOCKED = "blocked"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
class SubscriptionStatus(Enum):
|
||||
TRIAL = "trial"
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
|
||||
username = Column(String(255), nullable=True)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
last_name = Column(String(255), nullable=True)
|
||||
status = Column(String(20), default=UserStatus.ACTIVE.value)
|
||||
language = Column(String(5), default="ru")
|
||||
balance_kopeks = Column(Integer, default=0)
|
||||
used_promocodes = Column(Integer, default=0)
|
||||
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
|
||||
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
referral_code = Column(String(20), unique=True, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
last_activity = Column(DateTime, default=func.now())
|
||||
remnawave_uuid = Column(String(255), nullable=True, unique=True)
|
||||
broadcasts = relationship("BroadcastHistory", back_populates="admin")
|
||||
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
|
||||
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||
last_remnawave_sync = Column(DateTime, nullable=True)
|
||||
trojan_password = Column(String(255), nullable=True)
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
return self.balance_kopeks / 100
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
|
||||
def subtract_balance(self, kopeks: int) -> bool:
|
||||
if self.balance_kopeks >= kopeks:
|
||||
self.balance_kopeks -= kopeks
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
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)
|
||||
|
||||
start_date = Column(DateTime, default=func.now())
|
||||
end_date = Column(DateTime, nullable=False)
|
||||
|
||||
traffic_limit_gb = Column(Integer, default=0)
|
||||
traffic_used_gb = Column(Float, default=0.0)
|
||||
|
||||
subscription_url = Column(String, nullable=True)
|
||||
|
||||
device_limit = Column(Integer, default=1)
|
||||
|
||||
connected_squads = Column(JSON, default=list)
|
||||
|
||||
autopay_enabled = Column(Boolean, default=False)
|
||||
autopay_days_before = Column(Integer, default=3)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="subscription")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value and
|
||||
self.end_date > current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Проверяет, истёк ли срок подписки"""
|
||||
return self.end_date <= datetime.utcnow()
|
||||
|
||||
@property
|
||||
def should_be_expired(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value and
|
||||
self.end_date <= current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def actual_status(self) -> str:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if self.status == SubscriptionStatus.EXPIRED.value:
|
||||
return "expired"
|
||||
|
||||
if self.status == SubscriptionStatus.DISABLED.value:
|
||||
return "disabled"
|
||||
|
||||
if self.status == SubscriptionStatus.ACTIVE.value:
|
||||
if self.end_date <= current_time:
|
||||
return "expired"
|
||||
else:
|
||||
return "active"
|
||||
|
||||
if self.status == SubscriptionStatus.TRIAL.value:
|
||||
if self.end_date <= current_time:
|
||||
return "expired"
|
||||
else:
|
||||
return "trial"
|
||||
|
||||
return self.status
|
||||
|
||||
@property
|
||||
def status_display(self) -> str:
|
||||
actual_status = self.actual_status
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if actual_status == "expired":
|
||||
return "🔴 Истекла"
|
||||
elif actual_status == "active":
|
||||
if self.is_trial:
|
||||
return "🎯 Тестовая"
|
||||
else:
|
||||
return "🟢 Активна"
|
||||
elif actual_status == "disabled":
|
||||
return "⚫ Отключена"
|
||||
elif actual_status == "trial":
|
||||
return "🎯 Тестовая"
|
||||
|
||||
return "❓ Неизвестно"
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
actual_status = self.actual_status
|
||||
|
||||
if actual_status == "expired":
|
||||
return "🔴"
|
||||
elif actual_status == "active":
|
||||
if self.is_trial:
|
||||
return "🎁"
|
||||
else:
|
||||
return "💎"
|
||||
elif actual_status == "disabled":
|
||||
return "⚫"
|
||||
elif actual_status == "trial":
|
||||
return "🎁"
|
||||
|
||||
return "❓"
|
||||
|
||||
@property
|
||||
def days_left(self) -> int:
|
||||
current_time = datetime.utcnow()
|
||||
if self.end_date <= current_time:
|
||||
return 0
|
||||
delta = self.end_date - current_time
|
||||
return max(0, delta.days)
|
||||
|
||||
@property
|
||||
def time_left_display(self) -> str:
|
||||
current_time = datetime.utcnow()
|
||||
if self.end_date <= current_time:
|
||||
return "истёк"
|
||||
|
||||
delta = self.end_date - current_time
|
||||
days = delta.days
|
||||
hours = delta.seconds // 3600
|
||||
minutes = (delta.seconds % 3600) // 60
|
||||
|
||||
if days > 0:
|
||||
return f"{days} дн."
|
||||
elif hours > 0:
|
||||
return f"{hours} ч."
|
||||
else:
|
||||
return f"{minutes} мин."
|
||||
|
||||
@property
|
||||
def traffic_used_percent(self) -> float:
|
||||
if self.traffic_limit_gb == 0:
|
||||
return 0.0
|
||||
if self.traffic_limit_gb > 0:
|
||||
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
|
||||
return 0.0
|
||||
|
||||
def extend_subscription(self, days: int):
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
if self.end_date > datetime.utcnow():
|
||||
self.end_date = self.end_date + timedelta(days=days)
|
||||
else:
|
||||
self.end_date = datetime.utcnow() + timedelta(days=days)
|
||||
|
||||
if self.status == SubscriptionStatus.EXPIRED.value:
|
||||
self.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
def add_traffic(self, gb: int):
|
||||
if self.traffic_limit_gb == 0:
|
||||
return
|
||||
self.traffic_limit_gb += gb
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
type = Column(String(50), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
external_id = Column(String(255), nullable=True)
|
||||
|
||||
is_completed = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="transactions")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
__tablename__ = "promocodes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
type = Column(String(50), nullable=False)
|
||||
|
||||
balance_bonus_kopeks = Column(Integer, default=0)
|
||||
subscription_days = Column(Integer, default=0)
|
||||
|
||||
max_uses = Column(Integer, default=1)
|
||||
current_uses = Column(Integer, default=0)
|
||||
|
||||
valid_from = Column(DateTime, default=func.now())
|
||||
valid_until = Column(DateTime, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
uses = relationship("PromoCodeUse", back_populates="promocode")
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
now = datetime.utcnow()
|
||||
return (
|
||||
self.is_active and
|
||||
self.current_uses < self.max_uses and
|
||||
self.valid_from <= now and
|
||||
(self.valid_until is None or self.valid_until >= now)
|
||||
)
|
||||
|
||||
@property
|
||||
def uses_left(self) -> int:
|
||||
return max(0, self.max_uses - self.current_uses)
|
||||
|
||||
|
||||
class PromoCodeUse(Base):
|
||||
__tablename__ = "promocode_uses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
used_at = Column(DateTime, default=func.now())
|
||||
|
||||
promocode = relationship("PromoCode", back_populates="uses")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class ReferralEarning(Base):
|
||||
__tablename__ = "referral_earnings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
referral_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
reason = Column(String(100), nullable=False)
|
||||
|
||||
referral_transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id], back_populates="referral_earnings")
|
||||
referral = relationship("User", foreign_keys=[referral_id])
|
||||
referral_transaction = relationship("Transaction")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
|
||||
class Squad(Base):
|
||||
__tablename__ = "squads"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
uuid = Column(String(255), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
country_code = Column(String(5), nullable=True)
|
||||
|
||||
is_available = Column(Boolean, default=True)
|
||||
price_kopeks = Column(Integer, default=0)
|
||||
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
@property
|
||||
def price_rubles(self) -> float:
|
||||
return self.price_kopeks / 100
|
||||
|
||||
|
||||
class ServiceRule(Base):
|
||||
__tablename__ = "service_rules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
order = Column(Integer, default=0)
|
||||
title = Column(String(255), nullable=False)
|
||||
|
||||
content = Column(Text, nullable=False)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
language = Column(String(5), default="ru")
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(255), unique=True, nullable=False)
|
||||
value = Column(Text, nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class MonitoringLog(Base):
|
||||
__tablename__ = "monitoring_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
event_type = Column(String(100), nullable=False)
|
||||
|
||||
message = Column(Text, nullable=False)
|
||||
data = Column(JSON, nullable=True)
|
||||
|
||||
is_success = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
total_count = Column(Integer, default=0)
|
||||
sent_count = Column(Integer, default=0)
|
||||
failed_count = Column(Integer, default=0)
|
||||
status = Column(String(50), default="in_progress")
|
||||
admin_id = Column(Integer, ForeignKey("users.id"))
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
squad_uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
display_name = Column(String(255), nullable=False)
|
||||
|
||||
original_name = Column(String(255), nullable=True)
|
||||
|
||||
country_code = Column(String(5), nullable=True)
|
||||
|
||||
is_available = Column(Boolean, default=True)
|
||||
|
||||
price_kopeks = Column(Integer, default=0)
|
||||
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
max_users = Column(Integer, nullable=True)
|
||||
current_users = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
@property
|
||||
def price_rubles(self) -> float:
|
||||
return self.price_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
if self.max_users is None:
|
||||
return False
|
||||
return self.current_users >= self.max_users
|
||||
|
||||
@property
|
||||
def availability_status(self) -> str:
|
||||
if not self.is_available:
|
||||
return "Недоступен"
|
||||
elif self.is_full:
|
||||
return "Переполнен"
|
||||
else:
|
||||
return "Доступен"
|
||||
|
||||
|
||||
class SubscriptionServer(Base):
|
||||
__tablename__ = "subscription_servers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False)
|
||||
server_squad_id = Column(Integer, ForeignKey("server_squads.id"), nullable=False)
|
||||
|
||||
connected_at = Column(DateTime, default=func.now())
|
||||
|
||||
paid_price_kopeks = Column(Integer, default=0)
|
||||
|
||||
subscription = relationship("Subscription", backref="subscription_servers")
|
||||
server_squad = relationship("ServerSquad", backref="subscription_servers")
|
||||
@@ -0,0 +1,375 @@
|
||||
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 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()
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ YOOKASSA ===")
|
||||
yookassa_created = await create_yookassa_payments_table()
|
||||
if yookassa_created:
|
||||
logger.info("✅ Таблица YooKassa payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей YooKassa payments")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
|
||||
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
|
||||
|
||||
total_count = total_subs.fetchone()[0]
|
||||
unique_count = unique_users.fetchone()[0]
|
||||
|
||||
logger.info(f"Всего подписок: {total_count}")
|
||||
logger.info(f"Уникальных пользователей: {unique_count}")
|
||||
|
||||
if total_count == unique_count:
|
||||
logger.info("База данных уже в корректном состоянии")
|
||||
return True
|
||||
|
||||
deleted_count = await fix_subscription_duplicates_universal()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
final_check = await conn.execute(text("""
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM subscriptions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(*) > 1
|
||||
"""))
|
||||
|
||||
remaining_duplicates = final_check.fetchall()
|
||||
|
||||
if remaining_duplicates:
|
||||
logger.warning(f"Остались дубликаты у {len(remaining_duplicates)} пользователей")
|
||||
return False
|
||||
else:
|
||||
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===")
|
||||
return False
|
||||
Vendored
+620
@@ -0,0 +1,620 @@
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
from urllib.parse import urlparse
|
||||
import aiohttp
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
DISABLED = "DISABLED"
|
||||
LIMITED = "LIMITED"
|
||||
EXPIRED = "EXPIRED"
|
||||
|
||||
|
||||
class TrafficLimitStrategy(Enum):
|
||||
NO_RESET = "NO_RESET"
|
||||
DAY = "DAY"
|
||||
WEEK = "WEEK"
|
||||
MONTH = "MONTH"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveUser:
|
||||
uuid: str
|
||||
short_uuid: str
|
||||
username: str
|
||||
status: UserStatus
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
traffic_limit_bytes: int
|
||||
traffic_limit_strategy: TrafficLimitStrategy
|
||||
expire_at: datetime
|
||||
telegram_id: Optional[int]
|
||||
email: Optional[str]
|
||||
hwid_device_limit: Optional[int]
|
||||
description: Optional[str]
|
||||
tag: Optional[str]
|
||||
subscription_url: str
|
||||
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
|
||||
class RemnaWaveInternalSquad:
|
||||
uuid: str
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveNode:
|
||||
uuid: str
|
||||
name: str
|
||||
address: str
|
||||
country_code: str
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: Optional[int]
|
||||
traffic_used_bytes: Optional[int]
|
||||
traffic_limit_bytes: Optional[int]
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.response_data = response_data
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class RemnaWaveAPI:
|
||||
|
||||
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)
|
||||
|
||||
local_hosts = [
|
||||
'localhost', '127.0.0.1', 'remnawave',
|
||||
'remnawave-backend', 'app', 'api'
|
||||
]
|
||||
|
||||
if parsed.hostname in local_hosts:
|
||||
return "local"
|
||||
|
||||
if parsed.hostname:
|
||||
if (parsed.hostname.startswith('192.168.') or
|
||||
parsed.hostname.startswith('10.') or
|
||||
parsed.hostname.startswith('172.') or
|
||||
parsed.hostname.endswith('.local')):
|
||||
return "local"
|
||||
|
||||
return "external"
|
||||
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"🔗 Подключение к RemnaWave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
'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-Host': 'localhost',
|
||||
'Host': 'localhost'
|
||||
})
|
||||
|
||||
if self.base_url.startswith('https://'):
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
connector_kwargs['ssl'] = ssl_context
|
||||
logger.debug("🔓 SSL проверка отключена для локального HTTPS")
|
||||
|
||||
elif conn_type == "external":
|
||||
logger.debug("🌐 Использую внешнее подключение с полной SSL проверкой")
|
||||
pass
|
||||
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
|
||||
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):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
params: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
if not self.session:
|
||||
raise RemnaWaveAPIError("Session not initialized. Use async context manager.")
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
'url': url,
|
||||
'params': params
|
||||
}
|
||||
|
||||
if data:
|
||||
kwargs['json'] = data
|
||||
|
||||
async with self.session.request(method, **kwargs) as response:
|
||||
response_text = await response.text()
|
||||
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
|
||||
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,
|
||||
response_data
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
raise RemnaWaveAPIError(f"Request failed: {str(e)}")
|
||||
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
username: str,
|
||||
expire_at: datetime,
|
||||
status: UserStatus = UserStatus.ACTIVE,
|
||||
traffic_limit_bytes: int = 0,
|
||||
traffic_limit_strategy: TrafficLimitStrategy = TrafficLimitStrategy.NO_RESET,
|
||||
telegram_id: Optional[int] = None,
|
||||
email: Optional[str] = None,
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
active_internal_squads: Optional[List[str]] = None
|
||||
) -> RemnaWaveUser:
|
||||
data = {
|
||||
'username': username,
|
||||
'status': status.value,
|
||||
'expireAt': expire_at.isoformat(),
|
||||
'trafficLimitBytes': traffic_limit_bytes,
|
||||
'trafficLimitStrategy': traffic_limit_strategy.value
|
||||
}
|
||||
|
||||
if telegram_id:
|
||||
data['telegramId'] = telegram_id
|
||||
if email:
|
||||
data['email'] = email
|
||||
if hwid_device_limit:
|
||||
data['hwidDeviceLimit'] = hwid_device_limit
|
||||
if description:
|
||||
data['description'] = description
|
||||
if tag:
|
||||
data['tag'] = tag
|
||||
if active_internal_squads:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/{uuid}')
|
||||
return self._parse_user(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}')
|
||||
return [self._parse_user(user) for user in response['response']]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-username/{username}')
|
||||
return self._parse_user(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def update_user(
|
||||
self,
|
||||
uuid: str,
|
||||
status: Optional[UserStatus] = None,
|
||||
traffic_limit_bytes: Optional[int] = None,
|
||||
traffic_limit_strategy: Optional[TrafficLimitStrategy] = None,
|
||||
expire_at: Optional[datetime] = None,
|
||||
telegram_id: Optional[int] = None,
|
||||
email: Optional[str] = None,
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
active_internal_squads: Optional[List[str]] = None
|
||||
) -> RemnaWaveUser:
|
||||
data = {'uuid': uuid}
|
||||
|
||||
if status:
|
||||
data['status'] = status.value
|
||||
if traffic_limit_bytes is not None:
|
||||
data['trafficLimitBytes'] = traffic_limit_bytes
|
||||
if traffic_limit_strategy:
|
||||
data['trafficLimitStrategy'] = traffic_limit_strategy.value
|
||||
if expire_at:
|
||||
data['expireAt'] = expire_at.isoformat()
|
||||
if telegram_id is not None:
|
||||
data['telegramId'] = telegram_id
|
||||
if email is not None:
|
||||
data['email'] = email
|
||||
if hwid_device_limit is not None:
|
||||
data['hwidDeviceLimit'] = hwid_device_limit
|
||||
if description is not None:
|
||||
data['description'] = description
|
||||
if tag is not None:
|
||||
data['tag'] = tag
|
||||
if active_internal_squads is not None:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def delete_user(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/users/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def enable_user(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def disable_user(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
|
||||
data = {}
|
||||
if new_short_uuid:
|
||||
data['shortUuid'] = new_short_uuid
|
||||
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]:
|
||||
params = {'start': start, 'size': size}
|
||||
response = await self._make_request('GET', '/api/users', params=params)
|
||||
|
||||
return {
|
||||
'users': [self._parse_user(user) for user in response['response']['users']],
|
||||
'total': response['response']['total']
|
||||
}
|
||||
|
||||
|
||||
async def get_internal_squads(self) -> List[RemnaWaveInternalSquad]:
|
||||
response = await self._make_request('GET', '/api/internal-squads')
|
||||
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
|
||||
|
||||
async def get_internal_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveInternalSquad]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/internal-squads/{uuid}')
|
||||
return self._parse_internal_squad(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_internal_squad(self, name: str, inbounds: List[str]) -> RemnaWaveInternalSquad:
|
||||
data = {
|
||||
'name': name,
|
||||
'inbounds': inbounds
|
||||
}
|
||||
response = await self._make_request('POST', '/api/internal-squads', data)
|
||||
return self._parse_internal_squad(response['response'])
|
||||
|
||||
async def update_internal_squad(
|
||||
self,
|
||||
uuid: str,
|
||||
name: Optional[str] = None,
|
||||
inbounds: Optional[List[str]] = None
|
||||
) -> RemnaWaveInternalSquad:
|
||||
data = {'uuid': uuid}
|
||||
if name:
|
||||
data['name'] = name
|
||||
if inbounds is not None:
|
||||
data['inbounds'] = inbounds
|
||||
|
||||
response = await self._make_request('PATCH', '/api/internal-squads', data)
|
||||
return self._parse_internal_squad(response['response'])
|
||||
|
||||
async def delete_internal_squad(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
|
||||
async def get_all_nodes(self) -> List[RemnaWaveNode]:
|
||||
response = await self._make_request('GET', '/api/nodes')
|
||||
return [self._parse_node(node) for node in response['response']]
|
||||
|
||||
async def get_node_by_uuid(self, uuid: str) -> Optional[RemnaWaveNode]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/nodes/{uuid}')
|
||||
return self._parse_node(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def enable_node(self, uuid: str) -> RemnaWaveNode:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/enable')
|
||||
return self._parse_node(response['response'])
|
||||
|
||||
async def disable_node(self, uuid: str) -> RemnaWaveNode:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/disable')
|
||||
return self._parse_node(response['response'])
|
||||
|
||||
async def restart_node(self, uuid: str) -> bool:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def restart_all_nodes(self) -> bool:
|
||||
response = await self._make_request('POST', '/api/nodes/actions/restart-all')
|
||||
return response['response']['eventSent']
|
||||
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
|
||||
return response['response']
|
||||
|
||||
|
||||
async def get_system_stats(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats')
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats/bandwidth')
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_statistics(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats/nodes')
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/nodes/usage/realtime')
|
||||
return response['response']
|
||||
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
|
||||
return response['response']
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return {'total': 0, 'devices': []}
|
||||
raise
|
||||
|
||||
async def reset_user_devices(self, user_uuid: str) -> bool:
|
||||
try:
|
||||
devices_info = await self.get_user_devices(user_uuid)
|
||||
devices = devices_info.get('devices', [])
|
||||
|
||||
if not devices:
|
||||
return True
|
||||
|
||||
failed_count = 0
|
||||
for device in devices:
|
||||
device_hwid = device.get('hwid')
|
||||
if device_hwid:
|
||||
try:
|
||||
delete_data = {
|
||||
"userUuid": user_uuid,
|
||||
"hwid": device_hwid
|
||||
}
|
||||
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
except Exception as device_error:
|
||||
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
|
||||
failed_count += 1
|
||||
|
||||
return failed_count < len(devices) / 2
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сбросе устройств: {e}")
|
||||
return False
|
||||
|
||||
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
|
||||
try:
|
||||
delete_data = {
|
||||
"userUuid": user_uuid,
|
||||
"hwid": device_hwid
|
||||
}
|
||||
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
username=user_data['username'],
|
||||
status=UserStatus(user_data['status']),
|
||||
used_traffic_bytes=int(user_data['usedTrafficBytes']),
|
||||
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
|
||||
traffic_limit_bytes=user_data['trafficLimitBytes'],
|
||||
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
|
||||
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
|
||||
telegram_id=user_data.get('telegramId'),
|
||||
email=user_data.get('email'),
|
||||
hwid_device_limit=user_data.get('hwidDeviceLimit'),
|
||||
description=user_data.get('description'),
|
||||
tag=user_data.get('tag'),
|
||||
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')),
|
||||
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(
|
||||
uuid=squad_data['uuid'],
|
||||
name=squad_data['name'],
|
||||
members_count=squad_data['info']['membersCount'],
|
||||
inbounds_count=squad_data['info']['inboundsCount'],
|
||||
inbounds=squad_data['inbounds']
|
||||
)
|
||||
|
||||
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
|
||||
return RemnaWaveNode(
|
||||
uuid=node_data['uuid'],
|
||||
name=node_data['name'],
|
||||
address=node_data['address'],
|
||||
country_code=node_data['countryCode'],
|
||||
is_connected=node_data['isConnected'],
|
||||
is_disabled=node_data['isDisabled'],
|
||||
is_node_online=node_data['isNodeOnline'],
|
||||
is_xray_running=node_data['isXrayRunning'],
|
||||
users_online=node_data.get('usersOnline'),
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes')
|
||||
)
|
||||
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
if bytes_value == 0:
|
||||
return "0 B"
|
||||
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = bytes_value
|
||||
unit_index = 0
|
||||
|
||||
while size >= 1024 and unit_index < len(units) - 1:
|
||||
size /= 1024
|
||||
unit_index += 1
|
||||
|
||||
return f"{size:.1f} {units[unit_index]}"
|
||||
|
||||
|
||||
def parse_bytes(size_str: str) -> int:
|
||||
size_str = size_str.upper().strip()
|
||||
|
||||
units = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 ** 2,
|
||||
'GB': 1024 ** 3,
|
||||
'TB': 1024 ** 4
|
||||
}
|
||||
|
||||
for unit, multiplier in units.items():
|
||||
if size_str.endswith(unit):
|
||||
try:
|
||||
value = float(size_str[:-len(unit)].strip())
|
||||
return int(value * multiplier)
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def test_api_connection(api: RemnaWaveAPI) -> bool:
|
||||
try:
|
||||
await api.get_system_stats()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"API connection test failed: {e}")
|
||||
return False
|
||||
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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,
|
||||
title: str,
|
||||
description: str,
|
||||
amount_kopeks: int,
|
||||
payload: str,
|
||||
start_parameter: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label=title, amount=stars_amount)],
|
||||
start_parameter=start_parameter
|
||||
)
|
||||
|
||||
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:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
return None
|
||||
|
||||
async def send_invoice(
|
||||
self,
|
||||
chat_id: int,
|
||||
title: str,
|
||||
description: str,
|
||||
amount_kopeks: int,
|
||||
payload: str,
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label=title, amount=stars_amount)],
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки Stars invoice: {e}")
|
||||
return None
|
||||
|
||||
async def answer_pre_checkout_query(
|
||||
self,
|
||||
pre_checkout_query_id: str,
|
||||
ok: bool = True,
|
||||
error_message: Optional[str] = None
|
||||
) -> bool:
|
||||
try:
|
||||
await self.bot.answer_pre_checkout_query(
|
||||
pre_checkout_query_id=pre_checkout_query_id,
|
||||
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
|
||||
Vendored
+162
@@ -0,0 +1,162 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TributeService:
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = settings.TRIBUTE_API_KEY
|
||||
self.webhook_secret = settings.TRIBUTE_WEBHOOK_SECRET
|
||||
self.donate_link = settings.TRIBUTE_DONATE_LINK
|
||||
|
||||
async def create_payment_link(
|
||||
self,
|
||||
user_id: int,
|
||||
amount_kopeks: int = 0,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> Optional[str]:
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
logger.warning("Tribute платежи отключены")
|
||||
return None
|
||||
|
||||
try:
|
||||
payment_url = f"{self.donate_link}&user_id={user_id}"
|
||||
|
||||
logger.info(f"Создана ссылка Tribute для пользователя {user_id}")
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute ссылки: {e}")
|
||||
return None
|
||||
|
||||
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
|
||||
|
||||
if not self.webhook_secret:
|
||||
logger.warning("Webhook secret не настроен, пропускаем проверку")
|
||||
return True
|
||||
|
||||
try:
|
||||
expected_signature = hmac.new(
|
||||
self.webhook_secret.encode(),
|
||||
payload.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
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_or_data) -> Optional[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
logger.info(f"🔄 Начинаем обработку Tribute webhook")
|
||||
|
||||
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
|
||||
|
||||
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_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 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")
|
||||
|
||||
if event_name == "new_donation":
|
||||
status = "paid"
|
||||
elif event_name == "cancelled_subscription":
|
||||
status = "cancelled"
|
||||
else:
|
||||
status = "unknown"
|
||||
|
||||
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(f"🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
|
||||
return None
|
||||
|
||||
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": telegram_user_id,
|
||||
"amount_kopeks": int(amount_kopeks) if amount_kopeks else 0,
|
||||
"status": status or "paid",
|
||||
"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}", 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
|
||||
Vendored
+154
@@ -0,0 +1,154 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookServer:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.app = None
|
||||
self.runner = None
|
||||
self.site = None
|
||||
self.tribute_service = TributeService(bot)
|
||||
|
||||
async def create_app(self) -> web.Application:
|
||||
|
||||
self.app = web.Application()
|
||||
|
||||
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: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
|
||||
return self.app
|
||||
|
||||
async def start(self):
|
||||
|
||||
try:
|
||||
if not self.app:
|
||||
await self.create_app()
|
||||
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
|
||||
self.site = web.TCPSite(
|
||||
self.runner,
|
||||
host='0.0.0.0',
|
||||
port=settings.TRIBUTE_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
await self.site.start()
|
||||
|
||||
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"❌ Ошибка запуска Tribute webhook сервера: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
|
||||
try:
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
logger.info("Tribute webhook сайт остановлен")
|
||||
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("Tribute webhook runner очищен")
|
||||
|
||||
except Exception as 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)
|
||||
|
||||
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}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "internal_error", "message": str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
async def _health_check(self, request: web.Request) -> web.Response:
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "tribute-webhooks",
|
||||
"tribute_enabled": settings.TRIBUTE_ENABLED,
|
||||
"port": settings.TRIBUTE_WEBHOOK_PORT,
|
||||
"path": settings.TRIBUTE_WEBHOOK_PATH
|
||||
})
|
||||
Vendored
+243
@@ -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 @@
|
||||
# Инициализация админских обработчиков
|
||||
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_admin_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
admin_text = texts.ADMIN_PANEL
|
||||
|
||||
await callback.message.edit_text(
|
||||
admin_text,
|
||||
reply_markup=get_admin_main_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,560 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
from app.database.crud.subscription import get_expiring_subscriptions
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
📨 <b>Управление рассылками</b>
|
||||
|
||||
Выберите тип рассылки:
|
||||
|
||||
- <b>Всем пользователям</b> - рассылка всем активным пользователям
|
||||
- <b>По подпискам</b> - фильтрация по типу подписки
|
||||
- <b>По критериям</b> - настраиваемые фильтры
|
||||
- <b>История</b> - просмотр предыдущих рассылок
|
||||
|
||||
⚠️ Будьте осторожны с массовыми рассылками!
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_broadcast_targets(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🎯 <b>Выбор целевой аудитории</b>\n\n"
|
||||
"Выберите категорию пользователей для рассылки:",
|
||||
reply_markup=get_broadcast_target_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_history(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = 1
|
||||
if '_page_' in callback.data:
|
||||
page = int(callback.data.split('_page_')[1])
|
||||
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
stmt = select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
broadcasts = result.scalars().all()
|
||||
|
||||
count_stmt = select(func.count(BroadcastHistory.id))
|
||||
count_result = await db.execute(count_stmt)
|
||||
total_count = count_result.scalar() or 0
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
if not broadcasts:
|
||||
text = """
|
||||
📋 <b>История рассылок</b>
|
||||
|
||||
❌ История рассылок пуста.
|
||||
Отправьте первую рассылку, чтобы увидеть её здесь.
|
||||
"""
|
||||
keyboard = [[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]]
|
||||
else:
|
||||
text = f"📋 <b>История рассылок</b> (страница {page}/{total_pages})\n\n"
|
||||
|
||||
for broadcast in broadcasts:
|
||||
status_emoji = "✅" if broadcast.status == "completed" else "❌" if broadcast.status == "failed" else "⏳"
|
||||
success_rate = round((broadcast.sent_count / broadcast.total_count * 100), 1) if broadcast.total_count > 0 else 0
|
||||
|
||||
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
|
||||
|
||||
text += f"""
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
📝 Сообщение: <i>{message_preview}</i>
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_custom_broadcast(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_users_statistics(db)
|
||||
|
||||
text = f"""
|
||||
📝 <b>Рассылка по критериям</b>
|
||||
|
||||
📊 <b>Доступные фильтры:</b>
|
||||
|
||||
👥 <b>По регистрации:</b>
|
||||
• Сегодня: {stats['today']} чел.
|
||||
• За неделю: {stats['week']} чел.
|
||||
• За месяц: {stats['month']} чел.
|
||||
|
||||
💼 <b>По активности:</b>
|
||||
• Активные сегодня: {stats['active_today']} чел.
|
||||
• Неактивные 7+ дней: {stats['inactive_week']} чел.
|
||||
• Неактивные 30+ дней: {stats['inactive_month']} чел.
|
||||
|
||||
🔗 <b>По источнику:</b>
|
||||
• Через рефералов: {stats['referrals']} чел.
|
||||
• Прямая регистрация: {stats['direct']} чел.
|
||||
|
||||
Выберите критерий для фильтрации:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_custom_criteria_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_custom_criteria(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
criteria = callback.data.replace('criteria_', '')
|
||||
|
||||
criteria_names = {
|
||||
"today": "Зарегистрированные сегодня",
|
||||
"week": "Зарегистрированные за неделю",
|
||||
"month": "Зарегистрированные за месяц",
|
||||
"active_today": "Активные сегодня",
|
||||
"inactive_week": "Неактивные 7+ дней",
|
||||
"inactive_month": "Неактивные 30+ дней",
|
||||
"referrals": "Пришедшие через рефералов",
|
||||
"direct": "Прямая регистрация"
|
||||
}
|
||||
|
||||
user_count = await get_custom_users_count(db, criteria)
|
||||
|
||||
await state.update_data(broadcast_target=f"custom_{criteria}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📨 <b>Создание рассылки</b>\n\n"
|
||||
f"🎯 <b>Критерий:</b> {criteria_names.get(criteria, criteria)}\n"
|
||||
f"👥 <b>Получателей:</b> {user_count}\n\n"
|
||||
f"Введите текст сообщения для рассылки:\n\n"
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_broadcast_target(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
target = callback.data.split('_')[-1]
|
||||
|
||||
target_names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской"
|
||||
}
|
||||
|
||||
user_count = await get_target_users_count(db, target)
|
||||
|
||||
await state.update_data(broadcast_target=target)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📨 <b>Создание рассылки</b>\n\n"
|
||||
f"🎯 <b>Аудитория:</b> {target_names.get(target, target)}\n"
|
||||
f"👥 <b>Получателей:</b> {user_count}\n\n"
|
||||
f"Введите текст сообщения для рассылки:\n\n"
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_broadcast_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
broadcast_text = message.text
|
||||
|
||||
if len(broadcast_text) > 4000:
|
||||
await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
|
||||
await state.update_data(broadcast_message=broadcast_text)
|
||||
|
||||
target_display = get_target_display_name(target)
|
||||
|
||||
preview_text = f"""
|
||||
📨 <b>Предварительный просмотр рассылки</b>
|
||||
|
||||
🎯 <b>Аудитория:</b> {target_display}
|
||||
👥 <b>Получателей:</b> {user_count}
|
||||
|
||||
📝 <b>Сообщение:</b>
|
||||
{broadcast_text}
|
||||
|
||||
Подтвердить отправку?
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
]
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_broadcast(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
"⏳ Это может занять несколько минут.",
|
||||
reply_markup=None,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if target.startswith('custom_'):
|
||||
users = await get_custom_users(db, target.replace('custom_', ''))
|
||||
else:
|
||||
users = await get_target_users(db, target)
|
||||
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
total_count=len(users),
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
admin_id=db_user.id,
|
||||
admin_name=db_user.full_name,
|
||||
status="in_progress"
|
||||
)
|
||||
db.add(broadcast_history)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast_history)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = "completed" if failed_count == 0 else "partial"
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}")
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
users = await get_target_users(db, target)
|
||||
return len(users)
|
||||
|
||||
|
||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
if target == "all":
|
||||
return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
elif target == "active":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial]
|
||||
elif target == "trial":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_trial]
|
||||
elif target == "no":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if not user.subscription or not user.subscription.is_active]
|
||||
elif target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
users = await get_custom_users(db, criteria)
|
||||
return len(users)
|
||||
|
||||
|
||||
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
"""Получение пользователей по настраиваемым критериям"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
|
||||
if criteria == "today":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.created_at >= today)
|
||||
)
|
||||
elif criteria == "week":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.created_at >= week_ago)
|
||||
)
|
||||
elif criteria == "month":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.created_at >= month_ago)
|
||||
)
|
||||
elif criteria == "active_today":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.last_activity >= today)
|
||||
)
|
||||
elif criteria == "inactive_week":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.last_activity < week_ago)
|
||||
)
|
||||
elif criteria == "inactive_month":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.last_activity < month_ago)
|
||||
)
|
||||
elif criteria == "referrals":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.referred_by_id.isnot(None))
|
||||
)
|
||||
elif criteria == "direct":
|
||||
stmt = select(User).where(
|
||||
and_(
|
||||
User.status == "active",
|
||||
User.referred_by_id.is_(None)
|
||||
)
|
||||
)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
"""Получение статистики пользователей для отображения"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
|
||||
stats = {}
|
||||
|
||||
stats['today'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.created_at >= today)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['week'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.created_at >= week_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['month'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.created_at >= month_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['active_today'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.last_activity >= today)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['inactive_week'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.last_activity < week_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['inactive_month'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.last_activity < month_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['referrals'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.referred_by_id.isnot(None))
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['direct'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.status == "active",
|
||||
User.referred_by_id.is_(None)
|
||||
)
|
||||
)
|
||||
) or 0
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def get_target_name(target_type: str) -> str:
|
||||
names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской",
|
||||
"custom_today": "Зарегистрированные сегодня",
|
||||
"custom_week": "Зарегистрированные за неделю",
|
||||
"custom_month": "Зарегистрированные за месяц",
|
||||
"custom_active_today": "Активные сегодня",
|
||||
"custom_inactive_week": "Неактивные 7+ дней",
|
||||
"custom_inactive_month": "Неактивные 30+ дней",
|
||||
"custom_referrals": "Через рефералов",
|
||||
"custom_direct": "Прямая регистрация"
|
||||
}
|
||||
return names.get(target_type, target_type)
|
||||
|
||||
|
||||
def get_target_display_name(target: str) -> str:
|
||||
return get_target_name(target)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_messages_menu, F.data == "admin_messages")
|
||||
dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"]))
|
||||
dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_"))
|
||||
dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast")
|
||||
|
||||
dp.callback_query.register(show_messages_history, F.data.startswith("admin_msg_history"))
|
||||
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)
|
||||
@@ -0,0 +1,313 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.filters import Command
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
"""Главное меню мониторинга"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
|
||||
last_update = status['last_update'].strftime('%H:%M:%S') if status['last_update'] else "Никогда"
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Система мониторинга</b>
|
||||
|
||||
📊 <b>Статус:</b> {running_status}
|
||||
🕐 <b>Последнее обновление:</b> {last_update}
|
||||
⚙️ <b>Интервал проверки:</b> {settings.MONITORING_INTERVAL} мин
|
||||
|
||||
📈 <b>Статистика за 24 часа:</b>
|
||||
• Всего событий: {status['stats_24h']['total_events']}
|
||||
• Успешных: {status['stats_24h']['successful']}
|
||||
• Ошибок: {status['stats_24h']['failed']}
|
||||
• Успешность: {status['stats_24h']['success_rate']}%
|
||||
|
||||
🔧 Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = get_monitoring_keyboard()
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в админ меню мониторинга: {e}")
|
||||
await callback.answer("❌ Ошибка получения данных", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_start")
|
||||
@admin_required
|
||||
async def start_monitoring_callback(callback: CallbackQuery):
|
||||
try:
|
||||
if monitoring_service.is_running:
|
||||
await callback.answer("ℹ️ Мониторинг уже запущен")
|
||||
return
|
||||
|
||||
if not monitoring_service.bot:
|
||||
monitoring_service.bot = callback.bot
|
||||
|
||||
asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
await callback.answer("✅ Мониторинг запущен!")
|
||||
|
||||
await admin_monitoring_menu(callback)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга: {e}")
|
||||
await callback.answer(f"❌ Ошибка запуска: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_stop")
|
||||
@admin_required
|
||||
async def stop_monitoring_callback(callback: CallbackQuery):
|
||||
try:
|
||||
if not monitoring_service.is_running:
|
||||
await callback.answer("ℹ️ Мониторинг уже остановлен")
|
||||
return
|
||||
|
||||
monitoring_service.stop_monitoring()
|
||||
await callback.answer("⏹️ Мониторинг остановлен!")
|
||||
|
||||
await admin_monitoring_menu(callback)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки мониторинга: {e}")
|
||||
await callback.answer(f"❌ Ошибка остановки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_force_check")
|
||||
@admin_required
|
||||
async def force_check_callback(callback: CallbackQuery):
|
||||
try:
|
||||
await callback.answer("⏳ Выполняем проверку подписок...")
|
||||
|
||||
async for db in get_db():
|
||||
results = await monitoring_service.force_check_subscriptions(db)
|
||||
|
||||
text = f"""
|
||||
✅ <b>Принудительная проверка завершена</b>
|
||||
|
||||
📊 <b>Результаты проверки:</b>
|
||||
• Истекших подписок: {results['expired']}
|
||||
• Истекающих подписок: {results['expiring']}
|
||||
• Готовых к автооплате: {results['autopay_ready']}
|
||||
|
||||
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
|
||||
|
||||
Нажмите "Назад" для возврата в меню мониторинга.
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной проверки: {e}")
|
||||
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_logs")
|
||||
@admin_required
|
||||
async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
|
||||
|
||||
if not logs:
|
||||
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
|
||||
else:
|
||||
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
|
||||
|
||||
for log in logs:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
|
||||
message = log['message']
|
||||
if len(message) > 60:
|
||||
message = message[:60] + "..."
|
||||
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
if len(text) > 3500:
|
||||
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
|
||||
break
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_clear_logs")
|
||||
@admin_required
|
||||
async def clear_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
|
||||
|
||||
if deleted_count > 0:
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
|
||||
else:
|
||||
await callback.answer("ℹ️ Нет старых логов для удаления")
|
||||
|
||||
await monitoring_logs_callback(callback)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка очистки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_test_notifications")
|
||||
@admin_required
|
||||
async def test_notifications_callback(callback: CallbackQuery):
|
||||
"""Тест системы уведомлений"""
|
||||
try:
|
||||
test_message = f"""
|
||||
🧪 <b>Тестовое уведомление системы мониторинга</b>
|
||||
|
||||
Это тестовое сообщение для проверки работы системы уведомлений.
|
||||
|
||||
📊 <b>Статус системы:</b>
|
||||
• Мониторинг: {'🟢 Работает' if monitoring_service.is_running else '🔴 Остановлен'}
|
||||
• Уведомления: {'🟢 Включены' if settings.ENABLE_NOTIFICATIONS else '🔴 Отключены'}
|
||||
• Время теста: {datetime.now().strftime('%H:%M:%S %d.%m.%Y')}
|
||||
|
||||
✅ Если вы получили это сообщение, система уведомлений работает корректно!
|
||||
"""
|
||||
|
||||
await callback.bot.send_message(
|
||||
callback.from_user.id,
|
||||
test_message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Тестовое уведомление отправлено!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки тестового уведомления: {e}")
|
||||
await callback.answer(f"❌ Ошибка отправки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_statistics")
|
||||
@admin_required
|
||||
async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
mon_status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
week_ago = datetime.now() - timedelta(days=7)
|
||||
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
|
||||
|
||||
week_success = sum(1 for log in week_logs if log['is_success'])
|
||||
week_errors = len(week_logs) - week_success
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика мониторинга</b>
|
||||
|
||||
📱 <b>Подписки:</b>
|
||||
• Всего: {sub_stats['total_subscriptions']}
|
||||
• Активных: {sub_stats['active_subscriptions']}
|
||||
• Тестовых: {sub_stats['trial_subscriptions']}
|
||||
• Платных: {sub_stats['paid_subscriptions']}
|
||||
|
||||
📈 <b>За сегодня:</b>
|
||||
• Успешных операций: {mon_status['stats_24h']['successful']}
|
||||
• Ошибок: {mon_status['stats_24h']['failed']}
|
||||
• Успешность: {mon_status['stats_24h']['success_rate']}%
|
||||
|
||||
📊 <b>За неделю:</b>
|
||||
• Всего событий: {len(week_logs)}
|
||||
• Успешных: {week_success}
|
||||
• Ошибок: {week_errors}
|
||||
• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
|
||||
|
||||
🔧 <b>Система:</b>
|
||||
• Интервал: {settings.MONITORING_INTERVAL} мин
|
||||
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
|
||||
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.message(Command("monitoring"))
|
||||
@admin_required
|
||||
async def monitoring_command(message: Message):
|
||||
"""Команда /monitoring для быстрого доступа"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Быстрый статус мониторинга</b>
|
||||
|
||||
📊 <b>Статус:</b> {running_status}
|
||||
📈 <b>События за 24ч:</b> {status['stats_24h']['total_events']}
|
||||
✅ <b>Успешность:</b> {status['stats_24h']['success_rate']}%
|
||||
|
||||
Для подробного управления используйте админ-панель.
|
||||
"""
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка команды /monitoring: {e}")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Регистрация обработчиков мониторинга"""
|
||||
dp.include_router(router)
|
||||
@@ -0,0 +1,999 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
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
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.promocode import (
|
||||
get_promocodes_list, get_promocodes_count, create_promocode,
|
||||
get_promocode_statistics, get_promocode_by_code, update_promocode,
|
||||
delete_promocode
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocodes_menu(
|
||||
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}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_promocodes_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocodes_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
promocodes = await get_promocodes_list(db, offset=offset, limit=limit)
|
||||
total_count = await get_promocodes_count(db)
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
if not promocodes:
|
||||
await callback.message.edit_text(
|
||||
"🎫 Промокоды не найдены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"🎫 <b>Список промокодов</b> (стр. {page}/{total_pages})\n\n"
|
||||
keyboard = []
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
|
||||
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 Дней: {promo.subscription_days}\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
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(
|
||||
page, total_pages, "admin_promo_list", "admin_promocodes", db_user.language
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocode_management(
|
||||
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
|
||||
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
text = f"""
|
||||
🎫 <b>Управление промокодом</b>
|
||||
|
||||
{type_emoji} <b>Код:</b> <code>{promo.code}</code>
|
||||
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
|
||||
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
|
||||
"""
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
text += f"📅 <b>Создан:</b> {format_datetime(promo.created_at)}\n"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Редактировать",
|
||||
callback_data=f"promo_edit_{promo.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Переключить статус",
|
||||
callback_data=f"promo_toggle_{promo.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=f"promo_stats_{promo.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=f"promo_delete_{promo.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocode_edit_menu(
|
||||
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>
|
||||
"""
|
||||
|
||||
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_edit_date_{promo.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📊 Количество использований",
|
||||
callback_data=f"promo_edit_uses_{promo.id}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
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}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_promocode_creation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🎫 <b>Создание промокода</b>\n\n"
|
||||
"Выберите тип промокода:",
|
||||
reply_markup=get_promocode_type_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_promocode_type(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
promo_type = callback.data.split('_')[-1]
|
||||
|
||||
type_names = {
|
||||
"balance": "💰 Пополнение баланса",
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка"
|
||||
}
|
||||
|
||||
await state.update_data(promocode_type=promo_type)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🎫 <b>Создание промокода</b>\n\n"
|
||||
f"Тип: {type_names.get(promo_type, promo_type)}\n\n"
|
||||
f"Введите код промокода (только латинские буквы и цифры):",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.creating_promocode)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_code(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
code = message.text.strip().upper()
|
||||
|
||||
if not code.isalnum() or len(code) < 3 or len(code) > 20:
|
||||
await message.answer("❌ Код должен содержать только латинские буквы и цифры (3-20 символов)")
|
||||
return
|
||||
|
||||
existing = await get_promocode_by_code(db, code)
|
||||
if existing:
|
||||
await message.answer("❌ Промокод с таким кодом уже существует")
|
||||
return
|
||||
|
||||
await state.update_data(promocode_code=code)
|
||||
|
||||
data = await state.get_data()
|
||||
promo_type = data.get('promocode_type')
|
||||
|
||||
if promo_type == "balance":
|
||||
await message.answer(
|
||||
f"💰 <b>Промокод:</b> <code>{code}</code>\n\n"
|
||||
f"Введите сумму пополнения баланса (в рублях):"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
elif promo_type == "days":
|
||||
await message.answer(
|
||||
f"📅 <b>Промокод:</b> <code>{code}</code>\n\n"
|
||||
f"Введите количество дней подписки:"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
elif promo_type == "trial":
|
||||
await message.answer(
|
||||
f"🎁 <b>Промокод:</b> <code>{code}</code>\n\n"
|
||||
f"Введите количество дней тестовой подписки:"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_value(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
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())
|
||||
|
||||
promo_type = data.get('promocode_type')
|
||||
|
||||
if promo_type == "balance" and (value < 1 or value > 10000):
|
||||
await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей")
|
||||
return
|
||||
elif promo_type in ["days", "trial"] and (value < 1 or value > 3650):
|
||||
await message.answer("❌ Количество дней должно быть от 1 до 3650")
|
||||
return
|
||||
|
||||
await state.update_data(promocode_value=value)
|
||||
|
||||
await message.answer(
|
||||
f"📊 Введите количество использований промокода (или 0 для безлимита):"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_uses)
|
||||
|
||||
except ValueError:
|
||||
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,
|
||||
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())
|
||||
|
||||
if max_uses < 0 or max_uses > 100000:
|
||||
await message.answer("❌ Количество использований должно быть от 0 до 100,000")
|
||||
return
|
||||
|
||||
if max_uses == 0:
|
||||
max_uses = 999999
|
||||
|
||||
await state.update_data(promocode_max_uses=max_uses)
|
||||
|
||||
await message.answer(
|
||||
f"⏰ Введите срок действия промокода в днях (или 0 для бессрочного):"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_expiry)
|
||||
|
||||
except ValueError:
|
||||
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(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
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())
|
||||
|
||||
if expiry_days < 0 or expiry_days > 3650:
|
||||
await message.answer("❌ Срок действия должен быть от 0 до 3650 дней")
|
||||
return
|
||||
|
||||
code = data.get('promocode_code')
|
||||
promo_type = data.get('promocode_type')
|
||||
value = data.get('promocode_value', 0)
|
||||
max_uses = data.get('promocode_max_uses', 1)
|
||||
|
||||
valid_until = None
|
||||
if expiry_days > 0:
|
||||
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
type_map = {
|
||||
"balance": PromoCodeType.BALANCE,
|
||||
"days": PromoCodeType.SUBSCRIPTION_DAYS,
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
|
||||
}
|
||||
|
||||
promocode = await create_promocode(
|
||||
db=db,
|
||||
code=code,
|
||||
type=type_map[promo_type],
|
||||
balance_bonus_kopeks=value * 100 if promo_type == "balance" else 0,
|
||||
subscription_days=value if promo_type in ["days", "trial"] else 0,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=db_user.id
|
||||
)
|
||||
|
||||
type_names = {
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка"
|
||||
}
|
||||
|
||||
summary_text = f"""
|
||||
✅ <b>Промокод создан!</b>
|
||||
|
||||
🎫 <b>Код:</b> <code>{promocode.code}</code>
|
||||
📝 <b>Тип:</b> {type_names.get(promo_type)}
|
||||
"""
|
||||
|
||||
if promo_type == "balance":
|
||||
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
|
||||
elif promo_type in ["days", "trial"]:
|
||||
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
|
||||
|
||||
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
|
||||
|
||||
if promocode.valid_until:
|
||||
summary_text += f"⏰ <b>Действует до:</b> {format_datetime(promocode.valid_until)}\n"
|
||||
|
||||
await message.answer(
|
||||
summary_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Создан промокод {code} администратором {db_user.telegram_id}")
|
||||
|
||||
except ValueError:
|
||||
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()
|
||||
|
||||
|
||||
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")
|
||||
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
|
||||
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
|
||||
|
||||
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
|
||||
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
|
||||
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_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)
|
||||
@@ -0,0 +1,72 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
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"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Пользователей с рефералами: {stats['users_with_referrals']}
|
||||
- Активных рефереров: {stats['active_referrers']}
|
||||
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
|
||||
|
||||
<b>За период:</b>
|
||||
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
|
||||
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
|
||||
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
|
||||
|
||||
<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"""
|
||||
|
||||
<b>Настройки:</b>
|
||||
- Бонус за регистрацию: {settings.format_price(settings.REFERRAL_REGISTRATION_REWARD)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Комиссия: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_rules_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
📋 <b>Управление правилами сервиса</b>
|
||||
|
||||
Текущие правила показываются пользователям при регистрации и в главном меню.
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_current_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_rules_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_rules = message.text
|
||||
|
||||
if len(new_rules) > 4000:
|
||||
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def save_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
new_rules = data.get('new_rules')
|
||||
|
||||
if not new_rules:
|
||||
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await create_or_update_rules(
|
||||
db=db,
|
||||
content=new_rules,
|
||||
language=db_user.language
|
||||
)
|
||||
|
||||
from app.localization.texts import clear_rules_cache
|
||||
clear_rules_cache()
|
||||
|
||||
from app.localization.texts import refresh_rules_cache
|
||||
await refresh_rules_cache(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила сервиса обновлены!</b>\n\n"
|
||||
"Новые правила сохранены в базе данных и будут показываться пользователям.\n\n"
|
||||
"Кеш правил очищен и обновлен.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Правила сервиса обновлены администратором {db_user.telegram_id}")
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения правил: {e}")
|
||||
await callback.answer("❌ Ошибка сохранения правил", show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_rules_management, F.data == "admin_rules")
|
||||
dp.callback_query.register(view_current_rules, F.data == "admin_view_rules")
|
||||
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
|
||||
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
|
||||
|
||||
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
|
||||
@@ -0,0 +1,962 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads, get_server_squad_by_id, update_server_squad,
|
||||
delete_server_squad, sync_with_remnawave, get_server_statistics,
|
||||
create_server_squad, get_available_server_squads
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_server_statistics(db)
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Управление серверами</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
• Всего серверов: {stats['total_servers']}
|
||||
• Доступные: {stats['available_servers']}
|
||||
• Недоступные: {stats['unavailable_servers']}
|
||||
• С подключениями: {stats['servers_with_connections']}
|
||||
|
||||
💰 <b>Выручка от серверов:</b>
|
||||
• Общая: {stats['total_revenue_rubles']:.2f} ₽
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync")
|
||||
],
|
||||
[
|
||||
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")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
servers, total_count = await get_all_server_squads(db, page=page, limit=10)
|
||||
total_pages = (total_count + 9) // 10
|
||||
|
||||
if not servers:
|
||||
text = "🌐 <b>Список серверов</b>\n\n❌ Серверы не найдены."
|
||||
else:
|
||||
text = f"🌐 <b>Список серверов</b>\n\n"
|
||||
text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n"
|
||||
|
||||
for i, server in enumerate(servers, 1 + (page - 1) * 10):
|
||||
status_emoji = "✅" if server.is_available else "❌"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text += f"{i}. {status_emoji} {server.display_name}\n"
|
||||
text += f" 💰 Цена: {price_text}"
|
||||
|
||||
if server.max_users:
|
||||
text += f" | 👥 {server.current_users}/{server.max_users}"
|
||||
|
||||
text += f"\n UUID: <code>{server.squad_uuid}</code>\n\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for i, server in enumerate(servers):
|
||||
row_num = i // 2
|
||||
if len(keyboard) <= row_num:
|
||||
keyboard.append([])
|
||||
|
||||
status_emoji = "✅" if server.is_available else "❌"
|
||||
keyboard[row_num].append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{status_emoji} {server.display_name[:15]}...",
|
||||
callback_data=f"admin_server_edit_{server.id}"
|
||||
)
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
if page > 1:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="⬅️", callback_data=f"admin_servers_list_page_{page-1}"
|
||||
))
|
||||
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}", callback_data="current_page"
|
||||
))
|
||||
|
||||
if page < total_pages:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="➡️", callback_data=f"admin_servers_list_page_{page+1}"
|
||||
))
|
||||
|
||||
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")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_servers_with_remnawave(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 Синхронизация с RemnaWave...\n\nПодождите, это может занять время.",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
squads = await remnawave_service.get_all_squads()
|
||||
|
||||
if not squads:
|
||||
await callback.message.edit_text(
|
||||
"❌ Не удалось получить данные о сквадах из RemnaWave.\n\nПроверьте настройки API.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
created, updated, disabled = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
|
||||
📊 <b>Результаты:</b>
|
||||
• Создано новых серверов: {created}
|
||||
• Обновлено существующих: {updated}
|
||||
• Отключено неактивных: {disabled}
|
||||
• Всего обработано: {len(squads)}
|
||||
|
||||
ℹ️ Новые серверы созданы как недоступные.
|
||||
Настройте их в списке серверов.
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации серверов: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ Ошибка синхронизации: {str(e)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_edit_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_availability(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not server.is_available
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
status_text = "включен" if new_status else "отключен"
|
||||
await callback.answer(f"✅ Сервер {status_text}!")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_price(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_price)
|
||||
|
||||
current_price = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💰 <b>Редактирование цены</b>\n\n"
|
||||
f"Текущая цена: <b>{current_price}</b>\n\n"
|
||||
f"Отправьте новую цену в рублях (например: 15.50) или 0 для бесплатного доступа:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_price_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
try:
|
||||
price_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if price_rubles < 0:
|
||||
await message.answer("❌ Цена не может быть отрицательной")
|
||||
return
|
||||
|
||||
if price_rubles > 10000:
|
||||
await message.answer("❌ Слишком высокая цена (максимум 10,000 ₽)")
|
||||
return
|
||||
|
||||
price_kopeks = int(price_rubles * 100)
|
||||
|
||||
server = await update_server_squad(db, server_id, price_kopeks=price_kopeks)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
price_text = f"{price_rubles:.2f} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
await message.answer(
|
||||
f"✅ Цена сервера изменена на: <b>{price_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат цены. Используйте числа (например: 15.50)")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_name(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_name)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ <b>Редактирование названия</b>\n\n"
|
||||
f"Текущее название: <b>{server.display_name}</b>\n\n"
|
||||
f"Отправьте новое название для сервера:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_name_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
new_name = message.text.strip()
|
||||
|
||||
if len(new_name) > 255:
|
||||
await message.answer("❌ Название слишком длинное (максимум 255 символов)")
|
||||
return
|
||||
|
||||
if len(new_name) < 3:
|
||||
await message.answer("❌ Название слишком короткое (минимум 3 символа)")
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, display_name=new_name)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Название сервера изменено на: <b>{new_name}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_server_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
text = f"""
|
||||
🗑️ <b>Удаление сервера</b>
|
||||
|
||||
Вы действительно хотите удалить сервер:
|
||||
<b>{server.display_name}</b>
|
||||
|
||||
⚠️ <b>Внимание!</b>
|
||||
Сервер можно удалить только если к нему нет активных подключений.
|
||||
|
||||
Это действие нельзя отменить!
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Да, удалить", callback_data=f"admin_server_delete_confirm_{server_id}"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_server_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
success = await delete_server_squad(db, server_id)
|
||||
|
||||
if success:
|
||||
await cache.delete("available_countries")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Сервер <b>{server.display_name}</b> успешно удален!",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📋 К списку серверов", callback_data="admin_servers_list")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\n"
|
||||
f"Возможно, к нему есть активные подключения.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_detailed_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_server_statistics(db)
|
||||
available_servers = await get_available_server_squads(db)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Подробная статистика серверов</b>
|
||||
|
||||
<b>🌐 Общая информация:</b>
|
||||
• Всего серверов: {stats['total_servers']}
|
||||
• Доступные: {stats['available_servers']}
|
||||
• Недоступные: {stats['unavailable_servers']}
|
||||
• С активными подключениями: {stats['servers_with_connections']}
|
||||
|
||||
<b>💰 Финансовая статистика:</b>
|
||||
• Общая выручка: {stats['total_revenue_rubles']:.2f} ₽
|
||||
• Средняя цена за сервер: {(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1)):.2f} ₽
|
||||
|
||||
<b>🔥 Топ серверов по цене:</b>
|
||||
"""
|
||||
|
||||
sorted_servers = sorted(available_servers, key=lambda x: x.price_kopeks, reverse=True)
|
||||
|
||||
for i, server in enumerate(sorted_servers[:5], 1):
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
text += f"{i}. {server.display_name} - {price_text}\n"
|
||||
|
||||
if not sorted_servers:
|
||||
text += "Нет доступных серверов\n"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_stats"),
|
||||
types.InlineKeyboardButton(text="📋 Список", callback_data="admin_servers_list")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_country(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_country)
|
||||
|
||||
current_country = server.country_code or "Не указан"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🌍 <b>Редактирование кода страны</b>\n\n"
|
||||
f"Текущий код страны: <b>{current_country}</b>\n\n"
|
||||
f"Отправьте новый код страны (например: RU, US, DE) или '-' для удаления:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_country_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
new_country = message.text.strip().upper()
|
||||
|
||||
if new_country == "-":
|
||||
new_country = None
|
||||
elif len(new_country) > 5:
|
||||
await message.answer("❌ Код страны слишком длинный (максимум 5 символов)")
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, country_code=new_country)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
country_text = new_country or "Удален"
|
||||
await message.answer(
|
||||
f"✅ Код страны изменен на: <b>{country_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_limit(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_limit)
|
||||
|
||||
current_limit = server.max_users or "Без лимита"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👥 <b>Редактирование лимита пользователей</b>\n\n"
|
||||
f"Текущий лимит: <b>{current_limit}</b>\n\n"
|
||||
f"Отправьте новый лимит пользователей (число) или 0 для безлимитного доступа:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_limit_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
try:
|
||||
limit = int(message.text.strip())
|
||||
|
||||
if limit < 0:
|
||||
await message.answer("❌ Лимит не может быть отрицательным")
|
||||
return
|
||||
|
||||
if limit > 10000:
|
||||
await message.answer("❌ Слишком большой лимит (максимум 10,000)")
|
||||
return
|
||||
|
||||
max_users = limit if limit > 0 else None
|
||||
|
||||
server = await update_server_squad(db, server_id, max_users=max_users)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
limit_text = f"{limit} пользователей" if limit > 0 else "Без лимита"
|
||||
await message.answer(
|
||||
f"✅ Лимит пользователей изменен на: <b>{limit_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат числа. Введите целое число.")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_description(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_description)
|
||||
|
||||
current_desc = server.description or "Не указано"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Редактирование описания</b>\n\n"
|
||||
f"Текущее описание:\n<i>{current_desc}</i>\n\n"
|
||||
f"Отправьте новое описание сервера или '-' для удаления:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_description_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
new_description = message.text.strip()
|
||||
|
||||
if new_description == "-":
|
||||
new_description = None
|
||||
elif len(new_description) > 1000:
|
||||
await message.answer("❌ Описание слишком длинное (максимум 1000 символов)")
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, description=new_description)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
desc_text = new_description or "Удалено"
|
||||
await message.answer(
|
||||
f"✅ Описание сервера изменено:\n\n<i>{desc_text}</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_server_user_counts_handler(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 Синхронизация счетчиков пользователей...",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import sync_server_user_counts
|
||||
|
||||
updated_count = await sync_server_user_counts(db)
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• Обновлено серверов: {updated_count}
|
||||
|
||||
Счетчики пользователей синхронизированы с реальными данными.
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync_counts")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации счетчиков: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ Ошибка синхронизации: {str(e)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_servers_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_servers_list(callback, db_user, db, page)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_servers_menu, F.data == "admin_servers")
|
||||
dp.callback_query.register(show_servers_list, F.data == "admin_servers_list")
|
||||
dp.callback_query.register(sync_servers_with_remnawave, F.data == "admin_servers_sync")
|
||||
dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts")
|
||||
dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats")
|
||||
|
||||
dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc"))
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
|
||||
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
|
||||
|
||||
dp.message.register(process_server_name_edit, AdminStates.editing_server_name)
|
||||
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
|
||||
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
|
||||
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
|
||||
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_"))
|
||||
@@ -0,0 +1,350 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_statistics_keyboard, get_period_selection_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period
|
||||
from app.database.crud.referral import get_referral_statistics
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_percentage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_statistics_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
📊 <b>Статистика системы</b>
|
||||
|
||||
Выберите раздел для просмотра статистики:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_statistics_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_service = UserService()
|
||||
stats = await user_service.get_user_statistics(db)
|
||||
|
||||
total_users = stats['total_users']
|
||||
active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0)
|
||||
|
||||
text = f"""
|
||||
👥 <b>Статистика пользователей</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Всего зарегистрировано: {stats['total_users']}
|
||||
- Активных: {stats['active_users']} ({active_rate})
|
||||
- Заблокированных: {stats['blocked_users']}
|
||||
|
||||
<b>Новые регистрации:</b>
|
||||
- Сегодня: {stats['new_today']}
|
||||
- За неделю: {stats['new_week']}
|
||||
- За месяц: {stats['new_month']}
|
||||
|
||||
<b>Активность:</b>
|
||||
- Коэффициент активности: {active_rate}
|
||||
- Рост за месяц: +{stats['new_month']} ({format_percentage(stats['new_month'] / total_users * 100 if total_users > 0 else 0)})
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_subscriptions_statistics(db)
|
||||
|
||||
total_subs = stats['total_subscriptions']
|
||||
conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0)
|
||||
|
||||
text = f"""
|
||||
📱 <b>Статистика подписок</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Всего подписок: {stats['total_subscriptions']}
|
||||
- Активных: {stats['active_subscriptions']}
|
||||
- Платных: {stats['paid_subscriptions']}
|
||||
- Триальных: {stats['trial_subscriptions']}
|
||||
|
||||
<b>Конверсия:</b>
|
||||
- Из триала в платную: {conversion_rate}
|
||||
- Активных платных: {stats['paid_subscriptions']}
|
||||
|
||||
<b>Продажи:</b>
|
||||
- Сегодня: {stats['purchased_today']}
|
||||
- За неделю: {stats['purchased_week']}
|
||||
- За месяц: {stats['purchased_month']}
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_revenue_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
now = datetime.utcnow()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
month_stats = await get_transactions_statistics(db, month_start, now)
|
||||
all_time_stats = await get_transactions_statistics(db)
|
||||
|
||||
text = f"""
|
||||
💰 <b>Статистика доходов</b>
|
||||
|
||||
<b>За текущий месяц:</b>
|
||||
- Доходы: {settings.format_price(month_stats['totals']['income_kopeks'])}
|
||||
- Расходы: {settings.format_price(month_stats['totals']['expenses_kopeks'])}
|
||||
- Прибыль: {settings.format_price(month_stats['totals']['profit_kopeks'])}
|
||||
- От подписок: {settings.format_price(month_stats['totals']['subscription_income_kopeks'])}
|
||||
|
||||
<b>Сегодня:</b>
|
||||
- Транзакций: {month_stats['today']['transactions_count']}
|
||||
- Доходы: {settings.format_price(month_stats['today']['income_kopeks'])}
|
||||
|
||||
<b>За все время:</b>
|
||||
- Общий доход: {settings.format_price(all_time_stats['totals']['income_kopeks'])}
|
||||
- Общая прибыль: {settings.format_price(all_time_stats['totals']['profit_kopeks'])}
|
||||
|
||||
<b>Способы оплаты:</b>
|
||||
"""
|
||||
|
||||
for method, data in month_stats['by_payment_method'].items():
|
||||
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()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
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"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Пользователей с рефералами: {stats['users_with_referrals']}
|
||||
- Активных рефереров: {stats['active_referrers']}
|
||||
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
|
||||
|
||||
<b>За период:</b>
|
||||
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
|
||||
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
|
||||
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
|
||||
|
||||
<b>Средние показатели:</b>
|
||||
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
|
||||
|
||||
<b>Топ рефереры:</b>
|
||||
"""
|
||||
|
||||
if stats['top_referrers']:
|
||||
for i, referrer in enumerate(stats['top_referrers'][:5], 1):
|
||||
name = referrer['display_name']
|
||||
earned = settings.format_price(referrer['total_earned_kopeks'])
|
||||
count = referrer['referrals_count']
|
||||
text += f"{i}. {name}: {earned} ({count} реф.)\n"
|
||||
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()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_summary_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_service = UserService()
|
||||
user_stats = await user_service.get_user_statistics(db)
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
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)
|
||||
|
||||
conversion_rate = 0
|
||||
if user_stats['total_users'] > 0:
|
||||
conversion_rate = sub_stats['paid_subscriptions'] / user_stats['total_users'] * 100
|
||||
|
||||
arpu = 0
|
||||
if user_stats['active_users'] > 0:
|
||||
arpu = revenue_stats['totals']['income_kopeks'] / user_stats['active_users']
|
||||
|
||||
text = f"""
|
||||
📊 <b>Общая сводка системы</b>
|
||||
|
||||
<b>Пользователи:</b>
|
||||
- Всего: {user_stats['total_users']}
|
||||
- Активных: {user_stats['active_users']}
|
||||
- Новых за месяц: {user_stats['new_month']}
|
||||
|
||||
<b>Подписки:</b>
|
||||
- Активных: {sub_stats['active_subscriptions']}
|
||||
- Платных: {sub_stats['paid_subscriptions']}
|
||||
- Конверсия: {format_percentage(conversion_rate)}
|
||||
|
||||
<b>Финансы (месяц):</b>
|
||||
- Доходы: {settings.format_price(revenue_stats['totals']['income_kopeks'])}
|
||||
- ARPU: {settings.format_price(int(arpu))}
|
||||
- Транзакций: {sum(data['count'] for data in revenue_stats['by_type'].values())}
|
||||
|
||||
<b>Рост:</b>
|
||||
- Пользователи: +{user_stats['new_month']} за месяц
|
||||
- Продажи: +{sub_stats['purchased_month']} за месяц
|
||||
|
||||
<b>Обновлено:</b> {format_datetime(datetime.utcnow())}
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_revenue_by_period(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
period = callback.data.split('_')[-1]
|
||||
|
||||
period_map = {
|
||||
"today": 1,
|
||||
"yesterday": 1,
|
||||
"week": 7,
|
||||
"month": 30,
|
||||
"all": 365
|
||||
}
|
||||
|
||||
days = period_map.get(period, 30)
|
||||
revenue_data = await get_revenue_by_period(db, days)
|
||||
|
||||
if period == "yesterday":
|
||||
yesterday = datetime.utcnow().date() - timedelta(days=1)
|
||||
revenue_data = [r for r in revenue_data if r['date'] == yesterday]
|
||||
elif period == "today":
|
||||
today = datetime.utcnow().date()
|
||||
revenue_data = [r for r in revenue_data if r['date'] == today]
|
||||
|
||||
total_revenue = sum(r['amount_kopeks'] for r in revenue_data)
|
||||
avg_daily = total_revenue / len(revenue_data) if revenue_data else 0
|
||||
|
||||
text = f"""
|
||||
📈 <b>Доходы за период: {period}</b>
|
||||
|
||||
<b>Сводка:</b>
|
||||
- Общий доход: {settings.format_price(total_revenue)}
|
||||
- Дней с данными: {len(revenue_data)}
|
||||
- Средний доход в день: {settings.format_price(int(avg_daily))}
|
||||
|
||||
<b>По дням:</b>
|
||||
"""
|
||||
|
||||
for revenue in revenue_data[-10:]:
|
||||
text += f"• {revenue['date'].strftime('%d.%m')}: {settings.format_price(revenue['amount_kopeks'])}\n"
|
||||
|
||||
if len(revenue_data) > 10:
|
||||
text += f"... и еще {len(revenue_data) - 10} дней"
|
||||
|
||||
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")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_statistics_menu, F.data == "admin_statistics")
|
||||
dp.callback_query.register(show_users_statistics, F.data == "admin_stats_users")
|
||||
dp.callback_query.register(show_subscriptions_statistics, F.data == "admin_stats_subs")
|
||||
dp.callback_query.register(show_revenue_statistics, F.data == "admin_stats_revenue")
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_stats_referrals")
|
||||
dp.callback_query.register(show_summary_statistics, F.data == "admin_stats_summary")
|
||||
dp.callback_query.register(show_revenue_by_period, F.data.startswith("period_"))
|
||||
|
||||
periods = ["today", "yesterday", "week", "month", "all"]
|
||||
for period in periods:
|
||||
dp.callback_query.register(
|
||||
show_revenue_by_period,
|
||||
F.data == f"period_{period}"
|
||||
)
|
||||
@@ -0,0 +1,497 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_subscriptions_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.subscription import (
|
||||
get_expiring_subscriptions, get_subscriptions_statistics, get_expired_subscriptions,
|
||||
get_all_subscriptions
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
|
||||
|
||||
def get_country_flag(country_name: str) -> str:
|
||||
flags = {
|
||||
'USA': '🇺🇸', 'United States': '🇺🇸', 'US': '🇺🇸',
|
||||
'Germany': '🇩🇪', 'DE': '🇩🇪', 'Deutschland': '🇩🇪',
|
||||
'Netherlands': '🇳🇱', 'NL': '🇳🇱', 'Holland': '🇳🇱',
|
||||
'United Kingdom': '🇬🇧', 'UK': '🇬🇧', 'GB': '🇬🇧',
|
||||
'Japan': '🇯🇵', 'JP': '🇯🇵',
|
||||
'France': '🇫🇷', 'FR': '🇫🇷',
|
||||
'Canada': '🇨🇦', 'CA': '🇨🇦',
|
||||
'Russia': '🇷🇺', 'RU': '🇷🇺',
|
||||
'Singapore': '🇸🇬', 'SG': '🇸🇬',
|
||||
}
|
||||
return flags.get(country_name, '🌍')
|
||||
|
||||
|
||||
async def get_users_by_countries(db: AsyncSession) -> dict:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(User.preferred_location, func.count(User.id))
|
||||
.where(User.preferred_location.isnot(None))
|
||||
.group_by(User.preferred_location)
|
||||
)
|
||||
|
||||
stats = {}
|
||||
for location, count in result.fetchall():
|
||||
if location:
|
||||
stats[location] = count
|
||||
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики по странам: {e}")
|
||||
return {}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_subscriptions_statistics(db)
|
||||
|
||||
text = f"""
|
||||
📱 <b>Управление подписками</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
- Всего: {stats['total_subscriptions']}
|
||||
- Активных: {stats['active_subscriptions']}
|
||||
- Платных: {stats['paid_subscriptions']}
|
||||
- Триальных: {stats['trial_subscriptions']}
|
||||
|
||||
📈 <b>Продажи:</b>
|
||||
- Сегодня: {stats['purchased_today']}
|
||||
- За неделю: {stats['purchased_week']}
|
||||
- За месяц: {stats['purchased_month']}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список подписок", callback_data="admin_subs_list"),
|
||||
types.InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
|
||||
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
|
||||
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
subscriptions, total_count = await get_all_subscriptions(db, page=page, limit=10)
|
||||
total_pages = (total_count + 9) // 10
|
||||
|
||||
if not subscriptions:
|
||||
text = "📱 <b>Список подписок</b>\n\n❌ Подписки не найдены."
|
||||
else:
|
||||
text = f"📱 <b>Список подписок</b>\n\n"
|
||||
text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n"
|
||||
|
||||
for i, sub in enumerate(subscriptions, 1 + (page - 1) * 10):
|
||||
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
|
||||
sub_type = "🎁" if sub.is_trial else "💎"
|
||||
status = "✅ Активна" if sub.is_active else "❌ Неактивна"
|
||||
|
||||
text += f"{i}. {sub_type} {user_info}\n"
|
||||
text += f" {status} | До: {format_datetime(sub.end_date)}\n"
|
||||
if sub.device_limit > 0:
|
||||
text += f" 📱 Устройств: {sub.device_limit}\n"
|
||||
text += "\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
if page > 1:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="⬅️", callback_data=f"admin_subs_list_page_{page-1}"
|
||||
))
|
||||
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}", callback_data="current_page"
|
||||
))
|
||||
|
||||
if page < total_pages:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="➡️", callback_data=f"admin_subs_list_page_{page+1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_list")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_expiring_subscriptions(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
expiring_3d = await get_expiring_subscriptions(db, 3)
|
||||
expiring_1d = await get_expiring_subscriptions(db, 1)
|
||||
expired = await get_expired_subscriptions(db)
|
||||
|
||||
text = f"""
|
||||
⏰ <b>Истекающие подписки</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
- Истекают через 3 дня: {len(expiring_3d)}
|
||||
- Истекают завтра: {len(expiring_1d)}
|
||||
- Уже истекли: {len(expired)}
|
||||
|
||||
<b>Истекают через 3 дня:</b>
|
||||
"""
|
||||
|
||||
for sub in expiring_3d[:5]:
|
||||
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
|
||||
sub_type = "🎁" if sub.is_trial else "💎"
|
||||
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
|
||||
|
||||
if len(expiring_3d) > 5:
|
||||
text += f"... и еще {len(expiring_3d) - 5}\n"
|
||||
|
||||
text += f"\n<b>Истекают завтра:</b>\n"
|
||||
for sub in expiring_1d[:5]:
|
||||
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
|
||||
sub_type = "🎁" if sub.is_trial else "💎"
|
||||
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
|
||||
|
||||
if len(expiring_1d) > 5:
|
||||
text += f"... и еще {len(expiring_1d) - 5}\n"
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📨 Отправить напоминания", callback_data="admin_send_expiry_reminders")],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_expiring")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_subscriptions_statistics(db)
|
||||
|
||||
expiring_3d = await get_expiring_subscriptions(db, 3)
|
||||
expiring_7d = await get_expiring_subscriptions(db, 7)
|
||||
expired = await get_expired_subscriptions(db)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Детальная статистика подписок</b>
|
||||
|
||||
<b>📱 Общая информация:</b>
|
||||
• Всего подписок: {stats['total_subscriptions']}
|
||||
• Активных: {stats['active_subscriptions']}
|
||||
• Неактивных: {stats['total_subscriptions'] - stats['active_subscriptions']}
|
||||
|
||||
<b>💎 По типам:</b>
|
||||
• Платных: {stats['paid_subscriptions']}
|
||||
• Триальных: {stats['trial_subscriptions']}
|
||||
|
||||
<b>📈 Продажи:</b>
|
||||
• Сегодня: {stats['purchased_today']}
|
||||
• За неделю: {stats['purchased_week']}
|
||||
• За месяц: {stats['purchased_month']}
|
||||
|
||||
<b>⏰ Истечение:</b>
|
||||
• Истекают через 3 дня: {len(expiring_3d)}
|
||||
• Истекают через 7 дней: {len(expiring_7d)}
|
||||
• Уже истекли: {len(expired)}
|
||||
|
||||
<b>💰 Конверсия:</b>
|
||||
• Из триала в платную: {stats.get('trial_to_paid_conversion', 0)}%
|
||||
• Продлений: {stats.get('renewals_count', 0)}
|
||||
"""
|
||||
|
||||
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_subscriptions")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_pricing_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = f"""
|
||||
⚙️ <b>Настройки цен</b>
|
||||
|
||||
<b>Периоды подписки:</b>
|
||||
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
|
||||
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
|
||||
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
|
||||
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
|
||||
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
|
||||
|
||||
<b>Трафик-пакеты:</b>
|
||||
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
|
||||
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
|
||||
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
|
||||
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
|
||||
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
|
||||
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
|
||||
|
||||
<b>Дополнительно:</b>
|
||||
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
|
||||
"""
|
||||
|
||||
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_subscriptions")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_countries_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
nodes_data = await remnawave_service.get_all_nodes()
|
||||
squads_data = await remnawave_service.get_all_squads()
|
||||
|
||||
text = "🌍 <b>Управление странами</b>\n\n"
|
||||
|
||||
if nodes_data:
|
||||
text += "<b>Доступные серверы:</b>\n"
|
||||
countries = {}
|
||||
|
||||
for node in nodes_data:
|
||||
country_code = node.get('country_code', 'XX')
|
||||
country_name = country_code
|
||||
|
||||
if country_name not in countries:
|
||||
countries[country_name] = []
|
||||
countries[country_name].append(node)
|
||||
|
||||
for country, nodes in countries.items():
|
||||
active_nodes = len([n for n in nodes if n.get('is_connected') and n.get('is_node_online')])
|
||||
total_nodes = len(nodes)
|
||||
|
||||
country_flag = get_country_flag(country)
|
||||
text += f"{country_flag} {country}: {active_nodes}/{total_nodes} серверов\n"
|
||||
|
||||
total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes)
|
||||
if total_users_online > 0:
|
||||
text += f" 👥 Пользователей онлайн: {total_users_online}\n"
|
||||
else:
|
||||
text += "❌ Не удалось загрузить данные о серверах\n"
|
||||
|
||||
if squads_data:
|
||||
text += f"\n<b>Всего сквадов:</b> {len(squads_data)}\n"
|
||||
|
||||
total_members = sum(squad.get('members_count', 0) for squad in squads_data)
|
||||
text += f"<b>Участников в сквадах:</b> {total_members}\n"
|
||||
|
||||
text += "\n<b>Сквады:</b>\n"
|
||||
for squad in squads_data[:5]:
|
||||
name = squad.get('name', 'Неизвестно')
|
||||
members = squad.get('members_count', 0)
|
||||
inbounds = squad.get('inbounds_count', 0)
|
||||
text += f"• {name}: {members} участников, {inbounds} inbound(s)\n"
|
||||
|
||||
if len(squads_data) > 5:
|
||||
text += f"... и еще {len(squads_data) - 5} сквадов\n"
|
||||
|
||||
user_stats = await get_users_by_countries(db)
|
||||
if user_stats:
|
||||
text += "\n<b>Пользователи по регионам:</b>\n"
|
||||
for country, count in user_stats.items():
|
||||
country_flag = get_country_flag(country)
|
||||
text += f"{country_flag} {country}: {count} пользователей\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных о странах: {e}")
|
||||
text = f"""
|
||||
🌍 <b>Управление странами</b>
|
||||
|
||||
❌ <b>Ошибка загрузки данных</b>
|
||||
Не удалось получить информацию о серверах.
|
||||
|
||||
Проверьте подключение к RemnaWave API.
|
||||
|
||||
<b>Детали ошибки:</b> {str(e)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"),
|
||||
types.InlineKeyboardButton(text="🔧 Сквады", callback_data="admin_rw_squads")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_expiry_reminders(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"📨 Отправка напоминаний...\n\nПодождите, это может занять время.",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
expiring_subs = await get_expiring_subscriptions(db, 1)
|
||||
sent_count = 0
|
||||
|
||||
for subscription in expiring_subs:
|
||||
if subscription.user:
|
||||
try:
|
||||
user = subscription.user
|
||||
days_left = max(1, subscription.days_left)
|
||||
|
||||
reminder_text = f"""
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days_left} день(а).
|
||||
|
||||
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
|
||||
|
||||
💎 Продлить подписку можно в главном меню.
|
||||
"""
|
||||
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=reminder_text
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки напоминания пользователю {subscription.user_id}: {e}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Напоминания отправлены: {sent_count} из {len(expiring_subs)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subs_expiring")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_subscriptions_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_subscriptions_list(callback, db_user, db, page)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_subscriptions_menu, F.data == "admin_subscriptions")
|
||||
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
|
||||
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
|
||||
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
|
||||
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
|
||||
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
|
||||
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_subscriptions_pagination,
|
||||
F.data.startswith("admin_subs_list_page_")
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,580 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import BalanceStates
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.crud.transaction import (
|
||||
get_user_transactions, get_user_transactions_count,
|
||||
create_transaction
|
||||
)
|
||||
from app.database.models import User, TransactionType, PaymentMethod
|
||||
from app.keyboards.inline import (
|
||||
get_balance_keyboard, get_payment_methods_keyboard,
|
||||
get_back_keyboard, get_pagination_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
balance_text = texts.BALANCE_INFO.format(
|
||||
balance=texts.format_price(db_user.balance_kopeks)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=get_balance_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_history(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offset = (page - 1) * TRANSACTIONS_PER_PAGE
|
||||
|
||||
raw_transactions = await get_user_transactions(
|
||||
db, db_user.id,
|
||||
limit=TRANSACTIONS_PER_PAGE * 3,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
seen_transactions = set()
|
||||
unique_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)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = "📊 <b>История операций</b>\n\n"
|
||||
|
||||
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)}"
|
||||
|
||||
text += f"{emoji} {amount_text}\n"
|
||||
text += f"📝 {transaction.description}\n"
|
||||
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
keyboard = []
|
||||
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_pagination_keyboard(
|
||||
page, total_pages, "balance_history", db_user.language
|
||||
)
|
||||
keyboard.extend(pagination_row)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_balance_history_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_balance_history(callback, db_user, db, page)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_payment_methods(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
payment_text = """
|
||||
💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
Выберите удобный для вас способ оплаты:
|
||||
|
||||
⭐ <b>Telegram Stars</b> - быстро и удобно
|
||||
💳 <b>Банковская карта</b> - через YooKassa/Tribute
|
||||
🛠️ <b>Через поддержку</b> - другие способы
|
||||
|
||||
Выберите способ пополнения:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
reply_markup=get_payment_methods_keyboard(0, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_stars_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.TOP_UP_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="stars")
|
||||
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,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
tribute_service = TributeService(callback.bot)
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=db_user.telegram_id,
|
||||
amount_kopeks=0,
|
||||
description="Пополнение баланса VPN"
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||
f"• Введите любую сумму от 100₽\n"
|
||||
f"• Безопасная оплата через Tribute\n"
|
||||
f"• Мгновенное зачисление на баланс\n"
|
||||
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
||||
f"Нажмите кнопку для перехода к оплате:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def request_support_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
support_text = f"""
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
Для пополнения баланса обратитесь в техподдержку:
|
||||
{settings.SUPPORT_USERNAME}
|
||||
|
||||
Укажите:
|
||||
• ID: {db_user.telegram_id}
|
||||
• Сумму пополнения
|
||||
• Способ оплаты
|
||||
|
||||
⏰ Время обработки: 1-24 часа
|
||||
|
||||
<b>Доступные способы:</b>
|
||||
• Криптовалюта
|
||||
• Переводы между банками
|
||||
• Другие платежные системы
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="💬 Написать в поддержку",
|
||||
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
|
||||
)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
support_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_topup_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
amount_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer("Минимальная сумма пополнения: 1 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
|
||||
return
|
||||
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "stars")
|
||||
|
||||
if payment_method == "stars":
|
||||
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
||||
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("Неизвестный способ оплаты")
|
||||
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@error_handler
|
||||
async def process_stars_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("⚠ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
try:
|
||||
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,
|
||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
f"📊 Курс: {settings.get_stars_rate():.2f}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("⚠ Ошибка создания платежа")
|
||||
|
||||
|
||||
@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):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_menu,
|
||||
F.data == "menu_balance"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_history,
|
||||
F.data == "balance_history"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_balance_history_pagination,
|
||||
F.data.startswith("balance_history_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_payment_methods,
|
||||
F.data == "balance_topup"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_stars_payment,
|
||||
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"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
request_support_topup,
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_unknown_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await callback.answer(
|
||||
"❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
async def handle_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await state.clear()
|
||||
await callback.message.edit_text(
|
||||
texts.OPERATION_CANCELLED,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_unknown_message(
|
||||
message: types.Message,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await message.answer(
|
||||
"❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
reply_markup=get_back_keyboard(db_user.language if db_user else "ru")
|
||||
)
|
||||
|
||||
|
||||
async def show_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
rules_text = texts.RULES_TEXT
|
||||
|
||||
await callback.message.edit_text(
|
||||
rules_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_cancel,
|
||||
F.data.in_(["cancel", "subscription_cancel"])
|
||||
)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.models import User
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
has_active_subscription = bool(db_user.subscription)
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
subscription_is_active = db_user.subscription.is_active
|
||||
|
||||
menu_text = texts.MAIN_MENU.format(
|
||||
user_name=db_user.full_name,
|
||||
subscription_status=_get_subscription_status(db_user, texts)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
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,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
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 show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
rules_text = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
if not rules_text:
|
||||
texts = get_texts(db_user.language)
|
||||
rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Правила сервиса</b>\n\n{rules_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_back_to_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await state.clear()
|
||||
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
return "❌ Отсутствует"
|
||||
|
||||
subscription = user.subscription
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if subscription.end_date <= current_time:
|
||||
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if subscription.is_trial:
|
||||
if days_left > 1:
|
||||
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left == 1:
|
||||
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
|
||||
|
||||
else:
|
||||
if days_left > 7:
|
||||
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left > 1:
|
||||
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
|
||||
elif days_left == 1:
|
||||
return f"💎 Активна\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return f"💎 Активна\n⚠️ истекает сегодня!"
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_back_to_menu,
|
||||
F.data == "back_to_menu"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_service_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import PromoCodeStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_promocode_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.PROMOCODE_ENTER,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(PromoCodeStates.waiting_for_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_promocode(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
if not code:
|
||||
await message.answer(
|
||||
"❌ Введите корректный промокод",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, db_user.id, code)
|
||||
|
||||
if result["success"]:
|
||||
await message.answer(
|
||||
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
"expired": texts.PROMOCODE_EXPIRED,
|
||||
"used": texts.PROMOCODE_USED,
|
||||
"already_used_by_user": texts.PROMOCODE_USED,
|
||||
"server_error": texts.ERROR
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
|
||||
await message.answer(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promocode_menu,
|
||||
F.data == "menu_promocode"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_promocode,
|
||||
PromoCodeStates.waiting_for_code
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
import logging
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_referral_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
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>Как работают награды:</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>Ваша реферальная ссылка:</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"
|
||||
referral_text += "\n"
|
||||
|
||||
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
|
||||
|
||||
await callback.message.edit_text(
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
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"🚀 Быстрое подключение\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=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
|
||||
f"<code>{invite_text}</code>",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_referral_info,
|
||||
F.data == "menu_referrals"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
create_invite_message,
|
||||
F.data == "referral_create_invite"
|
||||
)
|
||||
@@ -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 платежей")
|
||||
@@ -0,0 +1,834 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import RegistrationStates
|
||||
from app.database.crud.user import (
|
||||
get_user_by_telegram_id, create_user, get_user_by_referral_code
|
||||
)
|
||||
from app.database.models import UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard
|
||||
)
|
||||
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}")
|
||||
|
||||
referral_code = None
|
||||
if len(message.text.split()) > 1:
|
||||
potential_code = message.text.split()[1]
|
||||
referral_code = potential_code
|
||||
logger.info(f"🔎 Найден реферальный код: {referral_code}")
|
||||
|
||||
if referral_code:
|
||||
await state.set_data({'referral_code': referral_code})
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
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:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
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,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if user and user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию")
|
||||
|
||||
try:
|
||||
from app.services.user_service import UserService
|
||||
from app.database.models import (
|
||||
Subscription, Transaction, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
|
||||
if user.subscription:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
)
|
||||
logger.info(f"🗑️ Удалены записи SubscriptionServer")
|
||||
|
||||
if user.subscription:
|
||||
await db.delete(user.subscription)
|
||||
logger.info(f"🗑️ Удалена подписка пользователя")
|
||||
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user.id)
|
||||
)
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.referred_by_id = None
|
||||
|
||||
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)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка подготовки к восстановлению: {e}")
|
||||
await db.rollback()
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
language = 'ru'
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен русский язык по умолчанию")
|
||||
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Установлено состояние: {current_state}")
|
||||
|
||||
|
||||
async def process_rules_accept(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"📋 RULES: Начало обработки правил")
|
||||
logger.info(f"📊 Callback data: {callback.data}")
|
||||
logger.info(f"👤 User: {callback.from_user.id}")
|
||||
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||
|
||||
try:
|
||||
await callback.answer()
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
if callback.data == 'rules_accept':
|
||||
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с правилами удалено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"✅ Правила приняты! Завершаем регистрацию...",
|
||||
reply_markup=None
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||
|
||||
try:
|
||||
rules_required_text = getattr(texts, 'RULES_REQUIRED',
|
||||
"Для использования бота необходимо принять правила сервиса.")
|
||||
await callback.message.edit_text(
|
||||
rules_required_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
"Для использования бота необходимо принять правила сервиса.",
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True)
|
||||
await callback.answer("❌ Произошла ошибка. Попробуйте еще раз.", show_alert=True)
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
await callback.message.answer(
|
||||
"Произошла ошибка. Попробуйте принять правила еще раз:",
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def process_referral_code_input(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
referral_code = message.text.strip()
|
||||
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
await message.answer("✅ Реферальный код применен!")
|
||||
logger.info(f"✅ Реферальный код применен")
|
||||
else:
|
||||
await message.answer("❌ Неверный реферальный код")
|
||||
logger.info(f"❌ Неверный реферальный код")
|
||||
return
|
||||
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
|
||||
async def process_referral_code_skip(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}")
|
||||
await callback.answer()
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с вопросом о реферальном коде удалено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"✅ Завершаем регистрацию...",
|
||||
reply_markup=None
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
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)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
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
|
||||
|
||||
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 callback.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 callback.message.answer(f"Добро пожаловать, {user_name}!")
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}")
|
||||
|
||||
existing_user.username = callback.from_user.username
|
||||
existing_user.first_name = callback.from_user.first_name
|
||||
existing_user.last_name = callback.from_user.last_name
|
||||
existing_user.language = language
|
||||
existing_user.referred_by_id = referrer_id
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
|
||||
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=callback.from_user.id,
|
||||
username=callback.from_user.username,
|
||||
first_name=callback.from_user.first_name,
|
||||
last_name=callback.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
user_language = user.language
|
||||
has_had_paid_subscription = user.has_had_paid_subscription
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status_simple(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
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
try:
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await callback.message.answer(
|
||||
f"Добро пожаловать, {user_name}!\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,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as 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.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)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}")
|
||||
|
||||
existing_user.username = message.from_user.username
|
||||
existing_user.first_name = message.from_user.first_name
|
||||
existing_user.last_name = message.from_user.last_name
|
||||
existing_user.language = language
|
||||
existing_user.referred_by_id = referrer_id
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
|
||||
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, message.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
user_language = user.language
|
||||
has_had_paid_subscription = user.has_had_paid_subscription
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status_simple(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
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
try:
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await message.answer(
|
||||
f"Добро пожаловать, {user_name}!\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,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
|
||||
|
||||
|
||||
def _get_subscription_status(user, texts):
|
||||
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):
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
|
||||
def get_referral_code_keyboard(language: str):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="⭐️ Пропустить",
|
||||
callback_data="referral_skip"
|
||||
)]
|
||||
])
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
|
||||
|
||||
dp.message.register(
|
||||
cmd_start,
|
||||
Command("start")
|
||||
)
|
||||
logger.info("✅ Зарегистрирован cmd_start")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_rules_accept,
|
||||
F.data.in_(["rules_accept", "rules_decline"]),
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_referral_code_skip,
|
||||
F.data == "referral_skip",
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_skip")
|
||||
|
||||
dp.message.register(
|
||||
process_referral_code_input,
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
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 ===")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.database.models import User, Subscription
|
||||
from app.database.crud.user import get_user_by_id, subtract_user_balance
|
||||
from app.database.crud.subscription import get_expiring_subscriptions, extend_subscription
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
from app.keyboards.inline import get_autopay_notification_keyboard, get_subscription_expiring_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_subscription_expiring_notification(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
days_left: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
if subscription.is_trial:
|
||||
text = texts.TRIAL_ENDING_SOON.format(
|
||||
price=texts.format_price(30000)
|
||||
)
|
||||
else:
|
||||
autopay_status = texts.AUTOPAY_ENABLED_TEXT if subscription.autopay_enabled else texts.AUTOPAY_DISABLED_TEXT
|
||||
|
||||
if subscription.autopay_enabled:
|
||||
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
|
||||
else:
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
|
||||
text = texts.SUBSCRIPTION_EXPIRING_PAID.format(
|
||||
days=days_left,
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
autopay_status=autopay_status,
|
||||
action_text=action_text
|
||||
)
|
||||
|
||||
keyboard = get_subscription_expiring_keyboard(subscription.id, user.language)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Отправлено уведомление об истечении подписки пользователю {user.telegram_id}")
|
||||
return True
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки уведомления об истечении подписки: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def send_autopay_failed_notification(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
required_amount: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
text = texts.AUTOPAY_FAILED.format(
|
||||
balance=texts.format_price(user.balance_kopeks),
|
||||
required=texts.format_price(required_amount)
|
||||
)
|
||||
|
||||
keyboard = get_autopay_notification_keyboard(subscription.id, user.language)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Отправлено уведомление о неудачном автоплатеже пользователю {user.telegram_id}")
|
||||
return True
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки уведомления о неудачном автоплатеже: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def process_autopayment(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {subscription.user_id} не найден для автоплатежа")
|
||||
return False
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
renewal_cost = await subscription_service.calculate_renewal_price(
|
||||
subscription, 30, db
|
||||
)
|
||||
|
||||
if user.balance_kopeks < renewal_cost:
|
||||
logger.warning(f"Недостаточно средств для автоплатежа у пользователя {user.telegram_id}")
|
||||
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
|
||||
return False
|
||||
|
||||
success = await subtract_user_balance(
|
||||
db, user, renewal_cost,
|
||||
f"Автопродление подписки на 30 дней"
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(f"Ошибка списания средств для автоплатежа у пользователя {user.telegram_id}")
|
||||
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
|
||||
return False
|
||||
|
||||
await extend_subscription(db, subscription, 30)
|
||||
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=renewal_cost,
|
||||
description="Автопродление подписки на 30 дней"
|
||||
)
|
||||
|
||||
texts = get_texts(user.language)
|
||||
success_text = texts.AUTOPAY_SUCCESS.format(
|
||||
days=30,
|
||||
amount=texts.format_price(renewal_cost),
|
||||
new_end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M")
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Автоплатеж успешно выполнен для пользователя {user.telegram_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки автоплатежа: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_support_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_support_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SUPPORT_INFO,
|
||||
reply_markup=get_support_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_support_info,
|
||||
F.data == "menu_support"
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_id, add_user_balance
|
||||
from app.database.crud.transaction import create_transaction, get_transaction_by_external_id
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.external.tribute import TributeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def tribute_webhook(request):
|
||||
try:
|
||||
signature = request.headers.get('X-Signature', '')
|
||||
payload = await request.text()
|
||||
|
||||
tribute_service = TributeService()
|
||||
|
||||
if not tribute_service.verify_webhook_signature(payload, signature):
|
||||
logger.warning("Неверная подпись Tribute webhook")
|
||||
return web.Response(status=400, text="Invalid signature")
|
||||
|
||||
webhook_data = await request.json()
|
||||
processed_data = await tribute_service.process_webhook(webhook_data)
|
||||
|
||||
if not processed_data:
|
||||
logger.error("Ошибка обработки Tribute webhook")
|
||||
return web.Response(status=400, text="Invalid webhook data")
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
existing_transaction = await get_transaction_by_external_id(
|
||||
db, processed_data['payment_id'], PaymentMethod.TRIBUTE
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
logger.info(f"Платеж {processed_data['payment_id']} уже обработан")
|
||||
return web.Response(status=200, text="Already processed")
|
||||
|
||||
if processed_data['status'] == 'completed':
|
||||
user = await get_user_by_id(db, processed_data['user_id'])
|
||||
|
||||
if user:
|
||||
await add_user_balance(
|
||||
db, user, processed_data['amount_kopeks'],
|
||||
f"Пополнение через Tribute: {processed_data['payment_id']}"
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=processed_data['amount_kopeks'],
|
||||
description=f"Пополнение через Tribute",
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=processed_data['payment_id']
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Tribute платеж: {processed_data['payment_id']}")
|
||||
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Tribute webhook: {e}")
|
||||
await db.rollback()
|
||||
return web.Response(status=500, text="Internal error")
|
||||
finally:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в Tribute webhook: {e}")
|
||||
return web.Response(status=500, text="Internal error")
|
||||
|
||||
|
||||
async def handle_successful_payment(message: types.Message):
|
||||
try:
|
||||
payment = message.successful_payment
|
||||
|
||||
payload_parts = payment.invoice_payload.split('_')
|
||||
if len(payload_parts) >= 3 and payload_parts[0] == 'balance':
|
||||
user_id = int(payload_parts[1])
|
||||
amount_kopeks = int(payload_parts[2])
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
existing_transaction = await get_transaction_by_external_id(
|
||||
db, payment.telegram_payment_charge_id, PaymentMethod.TELEGRAM_STARS
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
logger.info(f"Stars платеж {payment.telegram_payment_charge_id} уже обработан")
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if user:
|
||||
await add_user_balance(
|
||||
db, user, amount_kopeks,
|
||||
f"Пополнение через Telegram Stars"
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars",
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=payment.telegram_payment_charge_id
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Stars платежа: {e}")
|
||||
await db.rollback()
|
||||
finally:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в обработчике Stars платежа: {e}")
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(pre_checkout_query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
logger.info(f"Pre-checkout query принят: {pre_checkout_query.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в pre-checkout query: {e}")
|
||||
await pre_checkout_query.answer(ok=False, error_message="Ошибка обработки платежа")
|
||||
@@ -0,0 +1,609 @@
|
||||
from typing import List, Optional
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data="admin_users"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REFERRALS, callback_data="admin_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"),
|
||||
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Все пользователи", callback_data="admin_users_list"),
|
||||
InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"),
|
||||
InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Все подписки", callback_data="admin_subs_list"),
|
||||
InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройки цен", callback_data="admin_subs_pricing"),
|
||||
InlineKeyboardButton(text="🌍 Управление странами", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🎫 Все промокоды", callback_data="admin_promo_list"),
|
||||
InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_promo_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"promo_edit_{promo_id}"),
|
||||
InlineKeyboardButton(text="🔄 Статус", callback_data=f"promo_toggle_{promo_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"promo_stats_{promo_id}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"promo_delete_{promo_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📨 Всем пользователям", callback_data="admin_msg_all"),
|
||||
InlineKeyboardButton(text="🎯 По подпискам", callback_data="admin_msg_by_sub")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔍 По критериям", callback_data="admin_msg_custom"),
|
||||
InlineKeyboardButton(text="📋 История", callback_data="admin_msg_history")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
|
||||
InlineKeyboardButton(text="⏸️ Остановить", callback_data="admin_mon_stop")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статус", callback_data="admin_mon_status"),
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_mon_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Системная статистика", callback_data="admin_rw_system"),
|
||||
InlineKeyboardButton(text="🖥️ Управление нодами", callback_data="admin_rw_nodes")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_rw_sync"),
|
||||
InlineKeyboardButton(text="🌍 Управление сквадами", callback_data="admin_rw_squads")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📈 Трафик", callback_data="admin_rw_traffic")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_stats_users"),
|
||||
InlineKeyboardButton(text="📱 Подписки", callback_data="admin_stats_subs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"),
|
||||
InlineKeyboardButton(text="🤝 Рефералы", callback_data="admin_stats_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
|
||||
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}"),
|
||||
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}")
|
||||
]
|
||||
]
|
||||
|
||||
if user_status == "active":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🚫 Заблокировать", callback_data=f"admin_user_block_{user_id}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
|
||||
])
|
||||
elif user_status == "blocked":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="✅ Разблокировать", callback_data=f"admin_user_unblock_{user_id}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
|
||||
])
|
||||
elif user_status == "deleted":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="❌ Пользователь удален", callback_data="noop")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_confirmation_keyboard(
|
||||
confirm_action: str,
|
||||
cancel_action: str = "admin_panel",
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.YES, callback_data=confirm_action),
|
||||
InlineKeyboardButton(text=texts.NO, callback_data=cancel_action)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Баланс", callback_data="promo_type_balance"),
|
||||
InlineKeyboardButton(text="📅 Дни подписки", callback_data="promo_type_days")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 Триал", callback_data="promo_type_trial")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {type_emoji} {promo.code}",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = []
|
||||
|
||||
if page > 1:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="⬅️", callback_data=f"admin_promo_list_page_{page - 1}")
|
||||
)
|
||||
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="➡️", callback_data=f"admin_promo_list_page_{page + 1}")
|
||||
)
|
||||
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Всем", callback_data="broadcast_all"),
|
||||
InlineKeyboardButton(text="📱 С подпиской", callback_data="broadcast_active")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 Триал", callback_data="broadcast_trial"),
|
||||
InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Сегодня", callback_data="criteria_today"),
|
||||
InlineKeyboardButton(text="📅 За неделю", callback_data="criteria_week")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📅 За месяц", callback_data="criteria_month"),
|
||||
InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
|
||||
InlineKeyboardButton(text="👤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"),
|
||||
InlineKeyboardButton(text="🎫 Использовали промокоды", callback_data="criteria_promocodes")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎯 Прямая регистрация", callback_data="criteria_direct")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = []
|
||||
|
||||
if page > 1:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="⬅️", callback_data=f"admin_msg_history_page_{page - 1}")
|
||||
)
|
||||
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="➡️", callback_data=f"admin_msg_history_page_{page + 1}")
|
||||
)
|
||||
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_msg_history")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
|
||||
[InlineKeyboardButton(text="🆕 Только новые", callback_data="sync_new_users")],
|
||||
[InlineKeyboardButton(text="📈 Обновить данные", callback_data="sync_update_data")],
|
||||
[
|
||||
InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate"),
|
||||
InlineKeyboardButton(text="🧹 Очистка", callback_data="sync_cleanup")
|
||||
],
|
||||
[InlineKeyboardButton(text="💡 Рекомендации", callback_data="sync_recommendations")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_sync_confirmation_keyboard(sync_type: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"confirm_{sync_type}")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rw_sync")]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if has_errors:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔄 Повторить", callback_data=f"sync_{sync_type}")
|
||||
])
|
||||
|
||||
if sync_type != "all_users":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_rw_system"),
|
||||
InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
|
||||
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="admin_remnawave")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
|
||||
def get_period_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Сегодня", callback_data="period_today"),
|
||||
InlineKeyboardButton(text="📅 Вчера", callback_data="period_yesterday")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Неделя", callback_data="period_week"),
|
||||
InlineKeyboardButton(text="📅 Месяц", callback_data="period_month")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Все время", callback_data="period_all")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_node_management_keyboard(node_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="▶️ Включить", callback_data=f"node_enable_{node_uuid}"),
|
||||
InlineKeyboardButton(text="⏸️ Отключить", callback_data=f"node_disable_{node_uuid}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить", callback_data=f"node_restart_{node_uuid}"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"node_stats_{node_uuid}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_nodes")
|
||||
]
|
||||
])
|
||||
|
||||
def get_squad_management_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Добавить всех пользователей", callback_data=f"squad_add_users_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Удалить всех пользователей", callback_data=f"squad_remove_users_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"squad_edit_{squad_uuid}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить сквад", callback_data=f"squad_delete_{squad_uuid}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_squads")
|
||||
]
|
||||
])
|
||||
|
||||
def get_squad_edit_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔧 Изменить инбаунды", callback_data=f"squad_edit_inbounds_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Переименовать", callback_data=f"squad_rename_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад к сквадам", callback_data=f"admin_squad_manage_{squad_uuid}")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
|
||||
InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"),
|
||||
InlineKeyboardButton(text="📝 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить старые", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="➕ Добавить сервер", callback_data="admin_servers_add"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_server_edit_keyboard(server_id: int, is_available: bool, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server_id}"),
|
||||
InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server_id}"),
|
||||
InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отключить" if is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server_id}"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_pagination_keyboard(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
callback_prefix: str,
|
||||
back_callback: str = "admin_panel",
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
row = []
|
||||
|
||||
if current_page > 1:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
|
||||
])
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,995 @@
|
||||
from typing import List, Optional
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.RULES_ACCEPT, callback_data="rules_accept"),
|
||||
InlineKeyboardButton(text=texts.RULES_DECLINE, callback_data="rules_decline")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_main_menu_keyboard(
|
||||
language: str = "ru",
|
||||
is_admin: bool = False,
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: 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}, 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=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
|
||||
|
||||
show_buy = not has_active_subscription or not subscription_is_active
|
||||
|
||||
subscription_buttons = []
|
||||
|
||||
if show_trial:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
|
||||
)
|
||||
|
||||
if show_buy:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
|
||||
)
|
||||
|
||||
if subscription_buttons:
|
||||
if len(subscription_buttons) == 2:
|
||||
keyboard.append(subscription_buttons)
|
||||
else:
|
||||
keyboard.append([subscription_buttons[0]])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
|
||||
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"),
|
||||
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
|
||||
|
||||
if is_admin:
|
||||
if settings.DEBUG:
|
||||
print("DEBUG KEYBOARD: Админ кнопка ДОБАВЛЕНА!")
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel")
|
||||
])
|
||||
else:
|
||||
if settings.DEBUG:
|
||||
print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена")
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
def get_subscription_keyboard(
|
||||
language: str = "ru",
|
||||
has_subscription: bool = False,
|
||||
is_trial: bool = False,
|
||||
subscription=None
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if has_subscription:
|
||||
if subscription and subscription.subscription_url:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🚀 Открыть в мини-приложении",
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
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:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
|
||||
if not is_trial and subscription and subscription.days_left <= 3:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⏰ Продлить", callback_data="subscription_extend")
|
||||
])
|
||||
|
||||
if not is_trial:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="💳 Автоплатеж", callback_data="subscription_autopay")
|
||||
])
|
||||
|
||||
if is_trial:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⚙️ Настройки подписки", callback_data="subscription_settings")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
texts = get_texts(language)
|
||||
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(-2, [
|
||||
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
|
||||
])
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 Активировать", callback_data="trial_activate"),
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
available_periods = settings.get_available_subscription_periods()
|
||||
|
||||
period_texts = {
|
||||
14: texts.PERIOD_14_DAYS,
|
||||
30: texts.PERIOD_30_DAYS,
|
||||
60: texts.PERIOD_60_DAYS,
|
||||
90: texts.PERIOD_90_DAYS,
|
||||
180: texts.PERIOD_180_DAYS,
|
||||
360: texts.PERIOD_360_DAYS
|
||||
}
|
||||
|
||||
for days in available_periods:
|
||||
if days in period_texts:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=period_texts[days],
|
||||
callback_data=f"period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
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 = []
|
||||
|
||||
traffic_packages = settings.get_traffic_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")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
|
||||
emoji = "✅" if country['uuid'] in selected else "⚪"
|
||||
|
||||
if country['price_kopeks'] > 0:
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})"
|
||||
else:
|
||||
price_text = " (Бесплатно)"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji} {country['name']}{price_text}",
|
||||
callback_data=f"country_{country['uuid']}"
|
||||
)
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Нет доступных серверов",
|
||||
callback_data="no_servers"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="✅ Продолжить", callback_data="countries_continue")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
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 "⚪"
|
||||
|
||||
button_text = f"{emoji} {devices}{price_text}"
|
||||
|
||||
buttons.append(
|
||||
InlineKeyboardButton(
|
||||
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")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")]
|
||||
])
|
||||
|
||||
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)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.CONFIRM, callback_data="subscription_confirm"),
|
||||
InlineKeyboardButton(text=texts.CANCEL, callback_data="subscription_cancel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data="balance_history"),
|
||||
InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⭐ Telegram Stars",
|
||||
callback_data="topup_stars"
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="💳 Банковская карта (YooKassa)",
|
||||
callback_data="topup_yookassa"
|
||||
)
|
||||
])
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="💎 Банковская карта (Tribute)",
|
||||
callback_data="topup_tribute"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🛠️ Через поддержку",
|
||||
callback_data="topup_support"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
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)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💳 Пополнить баланс",
|
||||
callback_data="balance_topup"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📱 Моя подписка",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⏰ Продлить подписку",
|
||||
callback_data="subscription_extend"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💳 Пополнить баланс",
|
||||
callback_data="balance_topup"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📱 Моя подписка",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.CREATE_INVITE, callback_data="referral_create_invite")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_support_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.CONTACT_SUPPORT, url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_pagination_keyboard(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
callback_prefix: str,
|
||||
language: str = "ru"
|
||||
) -> List[List[InlineKeyboardButton]]:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
row = []
|
||||
|
||||
if current_page > 1:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(row)
|
||||
|
||||
return keyboard
|
||||
|
||||
def get_confirmation_keyboard(
|
||||
confirm_data: str,
|
||||
cancel_data: str = "cancel",
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.YES, callback_data=confirm_data),
|
||||
InlineKeyboardButton(text=texts.NO, callback_data=cancel_data)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_autopay_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Включить", callback_data="autopay_enable"),
|
||||
InlineKeyboardButton(text="❌ Выключить", callback_data="autopay_disable")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настроить дни", callback_data="autopay_set_days")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_autopay_days_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
for days in [1, 3, 7, 14]:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{days} дн{_get_days_suffix(days)}",
|
||||
callback_data=f"autopay_days_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="subscription_autopay")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _get_days_suffix(days: int) -> str:
|
||||
if days == 1:
|
||||
return "ь"
|
||||
elif 2 <= days <= 4:
|
||||
return "я"
|
||||
else:
|
||||
return "ей"
|
||||
|
||||
|
||||
|
||||
def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
periods = [
|
||||
(14, f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"),
|
||||
(30, f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"),
|
||||
(60, f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"),
|
||||
(90, f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}")
|
||||
]
|
||||
|
||||
for days, text in periods:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
return get_back_keyboard(language)
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
traffic_packages = settings.get_traffic_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"add_traffic_{gb}")
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Пакеты трафика не настроены",
|
||||
callback_data="no_traffic_packages"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.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 = []
|
||||
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 100
|
||||
|
||||
max_add = min(5, max_devices - current_devices)
|
||||
|
||||
for add_count in range(1, max_add + 1):
|
||||
price = add_count * settings.PRICE_PER_DEVICE
|
||||
total_devices = current_devices + add_count
|
||||
|
||||
add_device_word = _get_device_declension(add_count)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📱 +{add_count} {add_device_word} (итого: {total_devices}) - {settings.format_price(price)}",
|
||||
callback_data=f"add_devices_{add_count}"
|
||||
)
|
||||
])
|
||||
|
||||
if max_add == 0:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Достигнут максимум устройств",
|
||||
callback_data="max_devices_reached"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
return get_back_keyboard(language)
|
||||
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"✅ Сбросить за {settings.format_price(price_kopeks)}",
|
||||
callback_data="confirm_reset_traffic"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⌛ Отмена", callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
def get_manage_countries_keyboard(
|
||||
countries: List[dict],
|
||||
selected: List[str],
|
||||
current_subscription_countries: List[str],
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
|
||||
is_currently_connected = country['uuid'] in current_subscription_countries
|
||||
is_selected = country['uuid'] in selected
|
||||
|
||||
if is_currently_connected:
|
||||
if is_selected:
|
||||
emoji = "✅"
|
||||
status = ""
|
||||
else:
|
||||
emoji = "➖"
|
||||
status = " (отключить БЕСПЛАТНО)"
|
||||
else:
|
||||
if is_selected:
|
||||
emoji = "➕"
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
|
||||
status = price_text
|
||||
else:
|
||||
emoji = "⚪"
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
|
||||
status = price_text
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji} {country['name']}{status}",
|
||||
callback_data=f"country_manage_{country['uuid']}"
|
||||
)
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Нет доступных серверов",
|
||||
callback_data="no_servers"
|
||||
)
|
||||
])
|
||||
|
||||
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")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_device_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text="📱 iOS (iPhone/iPad)", callback_data="device_guide_ios"),
|
||||
InlineKeyboardButton(text="🤖 Android", callback_data="device_guide_android")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💻 Windows", callback_data="device_guide_windows"),
|
||||
InlineKeyboardButton(text="🎯 macOS", callback_data="device_guide_mac")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📺 Android TV", callback_data="device_guide_tv")
|
||||
]
|
||||
]
|
||||
|
||||
if settings.CONNECT_BUTTON_MODE == "guide":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="📋 Показать ссылку подписки", callback_data="open_subscription_link")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_connection_guide_keyboard(
|
||||
subscription_url: str,
|
||||
app: dict,
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.handlers.subscription import create_deep_link
|
||||
|
||||
keyboard = []
|
||||
|
||||
if 'installationStep' in app and 'buttons' in app['installationStep']:
|
||||
app_buttons = []
|
||||
for button in app['installationStep']['buttons']:
|
||||
button_text = button['buttonText'].get(language, button['buttonText']['en'])
|
||||
app_buttons.append(
|
||||
InlineKeyboardButton(text=f"📥 {button_text}", url=button['buttonLink'])
|
||||
)
|
||||
if len(app_buttons) == 2:
|
||||
keyboard.append(app_buttons)
|
||||
app_buttons = []
|
||||
|
||||
if app_buttons:
|
||||
keyboard.append(app_buttons)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="📋 Скопировать ссылку подписки", url=subscription_url)
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Выбрать другое устройство", callback_data="subscription_connect")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К подписке", callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_app_selection_keyboard(
|
||||
device_type: str,
|
||||
apps: list,
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
for app in apps:
|
||||
app_name = app['name']
|
||||
if app.get('isFeatured', False):
|
||||
app_name = f"⭐ {app_name}"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=app_name,
|
||||
callback_data=f"app_{device_type}_{app['id']}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Выбрать другое устройство", callback_data="subscription_connect")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К подписке", callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_specific_app_keyboard(
|
||||
subscription_url: str,
|
||||
app: dict,
|
||||
device_type: str,
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.handlers.subscription import create_deep_link
|
||||
|
||||
keyboard = []
|
||||
|
||||
if 'installationStep' in app and 'buttons' in app['installationStep']:
|
||||
app_buttons = []
|
||||
for button in app['installationStep']['buttons']:
|
||||
button_text = button['buttonText'].get(language, button['buttonText']['en'])
|
||||
app_buttons.append(
|
||||
InlineKeyboardButton(text=f"📥 {button_text}", url=button['buttonLink'])
|
||||
)
|
||||
if len(app_buttons) == 2:
|
||||
keyboard.append(app_buttons)
|
||||
app_buttons = []
|
||||
|
||||
if app_buttons:
|
||||
keyboard.append(app_buttons)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="📋 Скопировать ссылку подписки", url=subscription_url)
|
||||
])
|
||||
|
||||
if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']:
|
||||
for button in app['additionalAfterAddSubscriptionStep']['buttons']:
|
||||
button_text = button['buttonText'].get(language, button['buttonText']['en'])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=button_text, url=button['buttonLink'])
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Другие приложения", callback_data=f"app_list_{device_type}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Выбрать другое устройство", callback_data="subscription_connect")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К подписке", callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,116 @@
|
||||
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove
|
||||
from typing import List
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
def get_main_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_BALANCE),
|
||||
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_PROMOCODE),
|
||||
KeyboardButton(text=texts.MENU_REFERRALS)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_SUPPORT),
|
||||
KeyboardButton(text=texts.MENU_RULES)
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=False
|
||||
)
|
||||
|
||||
|
||||
def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="👥 Пользователи"),
|
||||
KeyboardButton(text="📱 Подписки")
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🎫 Промокоды"),
|
||||
KeyboardButton(text="📨 Рассылки")
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📊 Статистика"),
|
||||
KeyboardButton(text="🔧 Мониторинг")
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🏠 Главное меню")
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=False
|
||||
)
|
||||
|
||||
|
||||
def get_cancel_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text=texts.CANCEL)]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text=texts.YES),
|
||||
KeyboardButton(text=texts.NO)
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="⏭️ Пропустить")]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def remove_keyboard() -> ReplyKeyboardRemove:
|
||||
return ReplyKeyboardRemove()
|
||||
|
||||
|
||||
def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📱 Отправить контакт", request_contact=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
@@ -0,0 +1,509 @@
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from app.config import settings
|
||||
|
||||
_cached_rules = {}
|
||||
|
||||
def _get_default_rules(language: str = "ru") -> str:
|
||||
if language == "en":
|
||||
return """
|
||||
🔒 <b>Service Usage Rules</b>
|
||||
|
||||
1. It is forbidden to use the service for illegal activities
|
||||
2. Copyright infringement is prohibited
|
||||
3. Spam and malware distribution are prohibited
|
||||
4. Using the service for DDoS attacks is prohibited
|
||||
5. One account - one user
|
||||
6. Refunds are made only in exceptional cases
|
||||
7. Administration reserves the right to block an account for violating the rules
|
||||
|
||||
<b>By accepting the rules, you agree to comply with them.</b>
|
||||
"""
|
||||
else:
|
||||
return """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
class Texts:
|
||||
def __init__(self, language: str = "ru"):
|
||||
self.language = language
|
||||
|
||||
@property
|
||||
def RULES_TEXT(self) -> str:
|
||||
if self.language in _cached_rules:
|
||||
return _cached_rules[self.language]
|
||||
|
||||
return _get_default_rules(self.language)
|
||||
|
||||
BACK = "⬅️ Назад"
|
||||
CANCEL = "❌ Отмена"
|
||||
CONFIRM = "✅ Подтвердить"
|
||||
CONTINUE = "➡️ Продолжить"
|
||||
YES = "✅ Да"
|
||||
NO = "❌ Нет"
|
||||
LOADING = "⏳ Загрузка..."
|
||||
ERROR = "❌ Произошла ошибка"
|
||||
SUCCESS = "✅ Успешно"
|
||||
|
||||
@staticmethod
|
||||
def format_price(kopeks: int) -> str:
|
||||
return f"{kopeks / 100:.2f} ₽"
|
||||
|
||||
@staticmethod
|
||||
def format_traffic(gb: float) -> str:
|
||||
if gb == 0:
|
||||
return "∞ (безлимит)"
|
||||
elif gb >= 1024:
|
||||
return f"{gb/1024:.1f} ТБ"
|
||||
else:
|
||||
return f"{gb:.0f} ГБ"
|
||||
|
||||
|
||||
class RussianTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("ru")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Добро пожаловать в VPN сервис!</b>
|
||||
|
||||
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
|
||||
|
||||
🔐 <b>Преимущества:</b>
|
||||
• Высокая скорость подключения
|
||||
• Серверы в разных странах
|
||||
• Надежная защита данных
|
||||
• Круглосуточная поддержка
|
||||
|
||||
Для начала работы выберите язык интерфейса:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
|
||||
|
||||
RULES_ACCEPT = "✅ Принимаю правила"
|
||||
RULES_DECLINE = "❌ Не принимаю"
|
||||
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
|
||||
|
||||
REFERRAL_CODE_QUESTION = """
|
||||
🤝 <b>У вас есть реферальный код от друга?</b>
|
||||
|
||||
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
|
||||
|
||||
Введите код или нажмите "Пропустить":
|
||||
"""
|
||||
|
||||
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
|
||||
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
|
||||
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
|
||||
|
||||
MAIN_MENU = """👤 <b>{user_name}</b>
|
||||
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
MENU_BALANCE = "💰 Баланс"
|
||||
MENU_SUBSCRIPTION = "📱 Подписка"
|
||||
MENU_TRIAL = "🧪 Тестовая подписка"
|
||||
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
|
||||
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
|
||||
MENU_PROMOCODE = "🎫 Промокод"
|
||||
MENU_REFERRALS = "🤝 Рефералы"
|
||||
MENU_SUPPORT = "🛠️ Техподдержка"
|
||||
MENU_RULES = "📋 Правила сервиса"
|
||||
MENU_LANGUAGE = "🌐 Язык"
|
||||
MENU_ADMIN = "⚙️ Админ-панель"
|
||||
BALANCE_BUTTON = "💰 Баланс: {balance}"
|
||||
BALANCE_BUTTON_ZERO = "💰 Баланс: 0.00 ₽"
|
||||
|
||||
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
|
||||
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
|
||||
SUBSCRIPTION_ACTIVE = "✅ Активна"
|
||||
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
|
||||
|
||||
SUBSCRIPTION_INFO = """
|
||||
📱 <b>Информация о подписке</b>
|
||||
|
||||
📊 <b>Статус:</b> {status}
|
||||
🎭 <b>Тип:</b> {type}
|
||||
📅 <b>Действует до:</b> {end_date}
|
||||
⏰ <b>Осталось дней:</b> {days_left}
|
||||
|
||||
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
|
||||
🌍 <b>Серверы:</b> {countries_count} стран
|
||||
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
"""
|
||||
|
||||
TRIAL_AVAILABLE = """
|
||||
🎁 <b>Тестовая подписка</b>
|
||||
|
||||
Вы можете получить бесплатную тестовую подписку:
|
||||
|
||||
⏰ <b>Период:</b> {days} дней
|
||||
📈 <b>Трафик:</b> {traffic} ГБ
|
||||
📱 <b>Устройства:</b> {devices} шт.
|
||||
🌍 <b>Сервер:</b> 1 страна
|
||||
|
||||
Активировать тестовую подписку?
|
||||
"""
|
||||
|
||||
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
|
||||
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
|
||||
|
||||
BUY_SUBSCRIPTION_START = """
|
||||
💎 <b>Настройка подписки</b>
|
||||
|
||||
Давайте настроим вашу подписку под ваши потребности.
|
||||
|
||||
Сначала выберите период подписки:
|
||||
"""
|
||||
|
||||
SELECT_PERIOD = "Выберите период:"
|
||||
SELECT_TRAFFIC = "Выберите пакет трафика:"
|
||||
SELECT_COUNTRIES = "Выберите страны:"
|
||||
SELECT_DEVICES = "Количество устройств:"
|
||||
|
||||
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
|
||||
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
|
||||
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
|
||||
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
|
||||
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
|
||||
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
|
||||
|
||||
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
|
||||
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
|
||||
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
|
||||
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
|
||||
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
|
||||
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
|
||||
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
|
||||
|
||||
SUBSCRIPTION_SUMMARY = """
|
||||
📋 <b>Итоговая конфигурация</b>
|
||||
|
||||
📅 <b>Период:</b> {period} дней
|
||||
📈 <b>Трафик:</b> {traffic}
|
||||
🌍 <b>Страны:</b> {countries}
|
||||
📱 <b>Устройства:</b> {devices}
|
||||
|
||||
💰 <b>Итого к оплате:</b> {total_price}
|
||||
|
||||
Подтвердить покупку?
|
||||
"""
|
||||
|
||||
INSUFFICIENT_BALANCE = "❌ Недостаточно средств на балансе. Пополните баланс и попробуйте снова."
|
||||
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
|
||||
|
||||
BALANCE_INFO = """
|
||||
💰 <b>Баланс: {balance}</b>
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
BALANCE_HISTORY = "📊 История операций"
|
||||
BALANCE_TOP_UP = "💳 Пополнить"
|
||||
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
|
||||
|
||||
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
|
||||
TOP_UP_METHODS = """
|
||||
💳 <b>Выберите способ оплаты</b>
|
||||
|
||||
Сумма: {amount}
|
||||
"""
|
||||
|
||||
TOP_UP_STARS = "⭐ Telegram Stars"
|
||||
TOP_UP_TRIBUTE = "💎 Банковская карта"
|
||||
|
||||
PROMOCODE_ENTER = "🎫 Введите промокод:"
|
||||
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
|
||||
PROMOCODE_INVALID = "❌ Неверный промокод"
|
||||
PROMOCODE_EXPIRED = "❌ Промокод истек"
|
||||
PROMOCODE_USED = "❌ Промокод уже использован"
|
||||
|
||||
REFERRAL_INFO = """
|
||||
🤝 <b>Реферальная программа</b>
|
||||
|
||||
👥 <b>Приглашено:</b> {referrals_count} друзей
|
||||
💰 <b>Заработано:</b> {earned_amount}
|
||||
|
||||
🔗 <b>Ваша реферальная ссылка:</b>
|
||||
<code>{referral_link}</code>
|
||||
|
||||
🎫 <b>Ваш промокод:</b>
|
||||
<code>{referral_code}</code>
|
||||
|
||||
💰 <b>Условия:</b>
|
||||
• За каждого друга: {registration_bonus}
|
||||
• Процент с пополнений: {commission_percent}%
|
||||
"""
|
||||
|
||||
REFERRAL_INVITE_MESSAGE = """
|
||||
🎯 <b>Приглашение в VPN сервис</b>
|
||||
|
||||
Привет! Приглашаю тебя в отличный VPN сервис!
|
||||
|
||||
🎁 По моей ссылке ты получишь бонус: {bonus}
|
||||
|
||||
🔗 Переходи: {link}
|
||||
🎫 Или используй промокод: {code}
|
||||
|
||||
💪 Быстро, надежно, недорого!
|
||||
"""
|
||||
|
||||
CREATE_INVITE = "📝 Создать приглашение"
|
||||
|
||||
TRIAL_ENDING_SOON = """
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через несколько часов.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {price}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_ACTIVE = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
|
||||
|
||||
⏰ Ориентировочное время завершения: неизвестно
|
||||
🔄 Попробуйте позже
|
||||
|
||||
Приносим извинения за временные неудобства.
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_API_ERROR = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен из-за проблем с подключением к серверам.
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {last_check}
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRING_PAID = """
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {end_date}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
|
||||
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
|
||||
Новая дата окончания: {new_end_date}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
|
||||
💰 Ваш баланс: {balance}
|
||||
💳 Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
SUPPORT_INFO = f"""
|
||||
🛠️ <b>Техническая поддержка</b>
|
||||
|
||||
По всем вопросам обращайтесь к нашей поддержке:
|
||||
|
||||
👤 {settings.SUPPORT_USERNAME}
|
||||
|
||||
Мы поможем с:
|
||||
• Настройкой подключения
|
||||
• Решением технических проблем
|
||||
• Вопросами по оплате
|
||||
• Другими вопросами
|
||||
|
||||
⏰ Время ответа: обычно в течение 1-2 часов
|
||||
"""
|
||||
|
||||
CONTACT_SUPPORT = "💬 Написать в поддержку"
|
||||
|
||||
ADMIN_PANEL = """
|
||||
⚙️ <b>Административная панель</b>
|
||||
|
||||
Выберите раздел для управления:
|
||||
"""
|
||||
|
||||
ADMIN_USERS = "👥 Пользователи"
|
||||
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
|
||||
ADMIN_PROMOCODES = "🎫 Промокоды"
|
||||
ADMIN_MESSAGES = "📨 Рассылки"
|
||||
ADMIN_MONITORING = "🔍 Мониторинг"
|
||||
ADMIN_REFERRALS = "🤝 Рефералы"
|
||||
ADMIN_RULES = "📋 Правила"
|
||||
ADMIN_REMNAWAVE = "🖥️ Remnawave"
|
||||
ADMIN_STATISTICS = "📊 Статистика"
|
||||
|
||||
ACCESS_DENIED = "❌ Доступ запрещен"
|
||||
USER_NOT_FOUND = "❌ Пользователь не найден"
|
||||
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
|
||||
INVALID_AMOUNT = "❌ Неверная сумма"
|
||||
OPERATION_CANCELLED = "❌ Операция отменена"
|
||||
|
||||
SUBSCRIPTION_EXPIRING = """
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days} дней.
|
||||
|
||||
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
Недостаточно средств на балансе: {balance}
|
||||
Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
|
||||
class EnglishTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("en")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Welcome to VPN Service!</b>
|
||||
|
||||
Our service provides fast and secure internet access without restrictions.
|
||||
|
||||
🔐 <b>Advantages:</b>
|
||||
• High connection speed
|
||||
• Servers in different countries
|
||||
• Reliable data protection
|
||||
• 24/7 support
|
||||
|
||||
To get started, select interface language:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
|
||||
|
||||
BACK = "⬅️ Back"
|
||||
CANCEL = "❌ Cancel"
|
||||
CONFIRM = "✅ Confirm"
|
||||
CONTINUE = "➡️ Continue"
|
||||
YES = "✅ Yes"
|
||||
NO = "❌ No"
|
||||
|
||||
MENU_BALANCE = "💰 Balance"
|
||||
MENU_SUBSCRIPTION = "📱 Subscription"
|
||||
MENU_TRIAL = "🎁 Trial subscription"
|
||||
|
||||
|
||||
LANGUAGES = {
|
||||
"ru": RussianTexts,
|
||||
"en": EnglishTexts
|
||||
}
|
||||
|
||||
|
||||
def get_texts(language: str = "ru") -> Texts:
|
||||
return LANGUAGES.get(language, RussianTexts)()
|
||||
|
||||
async def get_rules_from_db(language: str = "ru") -> str:
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
async for db in get_db():
|
||||
rules = await get_current_rules_content(db, language)
|
||||
if rules:
|
||||
_cached_rules[language] = rules
|
||||
return rules
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил из БД: {e}")
|
||||
|
||||
default_rules = _get_default_rules(language)
|
||||
_cached_rules[language] = default_rules
|
||||
return default_rules
|
||||
|
||||
def get_rules_sync(language: str = "ru") -> str:
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
rules = loop.run_until_complete(get_rules_from_db(language))
|
||||
loop.close()
|
||||
return rules
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил: {e}")
|
||||
return _get_default_rules(language)
|
||||
|
||||
async def refresh_rules_cache(language: str = "ru"):
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
del _cached_rules[language]
|
||||
|
||||
await get_rules_from_db(language)
|
||||
print(f"✅ Кеш правил для языка {language} обновлен")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка обновления кеша правил: {e}")
|
||||
|
||||
def clear_rules_cache():
|
||||
global _cached_rules
|
||||
_cached_rules.clear()
|
||||
print("✅ Кеш правил очищен")
|
||||
@@ -0,0 +1,169 @@
|
||||
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
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.states import RegistrationStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthMiddleware(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:
|
||||
return await handler(event, data)
|
||||
|
||||
if user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
db_user = await get_user_by_telegram_id(db, user.id)
|
||||
|
||||
if not db_user:
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
]
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (isinstance(event, CallbackQuery) and current_state and
|
||||
any(str(state) in str(current_state) for state in registration_states))
|
||||
or (isinstance(event, CallbackQuery) and event.data and
|
||||
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
|
||||
)
|
||||
|
||||
if is_registration_process:
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
data['is_admin'] = False
|
||||
return await handler(event, data)
|
||||
else:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(
|
||||
"▶️ Для начала работы необходимо выполнить команду /start"
|
||||
)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
"▶️ Необходимо начать с команды /start",
|
||||
show_alert=True
|
||||
)
|
||||
logger.info(f"🚫 Заблокирован незарегистрированный пользователь {user.id}")
|
||||
return
|
||||
else:
|
||||
from app.database.models import UserStatus
|
||||
|
||||
if db_user.status == UserStatus.BLOCKED.value:
|
||||
if isinstance(event, Message):
|
||||
await event.answer("🚫 Ваш аккаунт заблокирован администратором.")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer("🚫 Ваш аккаунт заблокирован администратором.", show_alert=True)
|
||||
logger.info(f"🚫 Заблокированный пользователь {user.id} попытался использовать бота")
|
||||
return
|
||||
|
||||
if db_user.status == UserStatus.DELETED.value:
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
]
|
||||
|
||||
is_start_or_registration = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (isinstance(event, CallbackQuery) and current_state and
|
||||
any(str(state) in str(current_state) for state in registration_states))
|
||||
or (isinstance(event, CallbackQuery) and event.data and
|
||||
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
|
||||
)
|
||||
|
||||
if is_start_or_registration:
|
||||
logger.info(f"🔄 Удаленный пользователь {user.id} начинает повторную регистрацию")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
data['is_admin'] = False
|
||||
return await handler(event, data)
|
||||
else:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(
|
||||
"❌ Ваш аккаунт был удален.\n"
|
||||
"🔄 Для повторной регистрации выполните команду /start"
|
||||
)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
"❌ Ваш аккаунт был удален. Для повторной регистрации выполните /start",
|
||||
show_alert=True
|
||||
)
|
||||
logger.info(f"❌ Удаленный пользователь {user.id} попытался использовать бота без /start")
|
||||
return
|
||||
|
||||
|
||||
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
|
||||
data['db_user'] = db_user
|
||||
data['is_admin'] = settings.is_admin(user.id)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в AuthMiddleware: {e}")
|
||||
logger.error(f"Event type: {type(event)}")
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f"Callback data: {event.data}")
|
||||
await db.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
|
||||
text = event.text or event.caption or "[медиа]"
|
||||
logger.info(f"📩 Сообщение от {user_info}: {text}")
|
||||
|
||||
elif isinstance(event, CallbackQuery):
|
||||
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
|
||||
logger.info(f"🔘 Callback от {user_info}: {event.data}")
|
||||
|
||||
result = await handler(event, data)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
if execution_time > 1.0:
|
||||
logger.warning(f"⏱️ Медленная операция: {execution_time:.2f}s")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
execution_time = time.time() - start_time
|
||||
logger.error(f"❌ Ошибка при обработке события за {execution_time:.2f}s: {e}")
|
||||
raise
|
||||
@@ -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,53 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from datetime import datetime
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionStatusMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
telegram_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
telegram_id = event.from_user.id
|
||||
elif isinstance(event, Update):
|
||||
if event.message:
|
||||
telegram_id = event.message.from_user.id
|
||||
elif event.callback_query:
|
||||
telegram_id = event.callback_query.from_user.id
|
||||
|
||||
if telegram_id:
|
||||
try:
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if user and user.subscription:
|
||||
current_time = datetime.utcnow()
|
||||
subscription = user.subscription
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}")
|
||||
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,53 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self, rate_limit: float = 0.5):
|
||||
self.rate_limit = rate_limit
|
||||
self.user_buckets: Dict[int, float] = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
user_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user_id = event.from_user.id
|
||||
|
||||
if not user_id:
|
||||
return await handler(event, data)
|
||||
|
||||
now = time.time()
|
||||
last_call = self.user_buckets.get(user_id, 0)
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f"🚫 Throttling для пользователя {user_id}")
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True)
|
||||
|
||||
return
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
cleanup_threshold = now - 60
|
||||
self.user_buckets = {
|
||||
uid: timestamp
|
||||
for uid, timestamp in self.user_buckets.items()
|
||||
if timestamp > cleanup_threshold
|
||||
}
|
||||
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Сервисы бизнес-логики
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -0,0 +1,710 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Set
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.subscription import (
|
||||
get_expired_subscriptions, get_expiring_subscriptions,
|
||||
get_subscriptions_for_autopay, deactivate_subscription,
|
||||
extend_subscription
|
||||
)
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_inactive_users, delete_user,
|
||||
subtract_user_balance
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MonitoringService:
|
||||
|
||||
def __init__(self, bot=None):
|
||||
self.is_running = False
|
||||
self.subscription_service = SubscriptionService()
|
||||
self.payment_service = PaymentService()
|
||||
self.bot = bot
|
||||
self._notified_users: Set[str] = set()
|
||||
|
||||
async def start_monitoring(self):
|
||||
if self.is_running:
|
||||
logger.warning("Мониторинг уже запущен")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
logger.info("🔄 Запуск службы мониторинга")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
await self._monitoring_cycle()
|
||||
await asyncio.sleep(settings.MONITORING_INTERVAL * 60)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле мониторинга: {e}")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
def stop_monitoring(self):
|
||||
self.is_running = False
|
||||
logger.info("ℹ️ Мониторинг остановлен")
|
||||
|
||||
async def _monitoring_cycle(self):
|
||||
async for db in get_db():
|
||||
try:
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
await self._process_autopayments(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
|
||||
current_hour = datetime.utcnow().hour
|
||||
if current_hour == 0:
|
||||
self._notified_users.clear()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "monitoring_cycle_completed",
|
||||
"Цикл мониторинга успешно завершен",
|
||||
{"timestamp": datetime.utcnow().isoformat()}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле мониторинга: {e}")
|
||||
await self._log_monitoring_event(
|
||||
db, "monitoring_cycle_error",
|
||||
f"Ошибка в цикле мониторинга: {str(e)}",
|
||||
{"error": str(e)},
|
||||
is_success=False
|
||||
)
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _check_expired_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
from app.database.crud.subscription import expire_subscription
|
||||
await expire_subscription(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.remnawave_uuid:
|
||||
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
if user and self.bot:
|
||||
await self._send_subscription_expired_notification(user)
|
||||
|
||||
logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и статус изменен на 'expired'")
|
||||
|
||||
if expired_subscriptions:
|
||||
await self._log_monitoring_event(
|
||||
db, "expired_subscriptions_processed",
|
||||
f"Обработано {len(expired_subscriptions)} истёкших подписок",
|
||||
{"count": len(expired_subscriptions)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истёкших подписок: {e}")
|
||||
|
||||
async def update_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
|
||||
return None
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date > current_time)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
await db.commit()
|
||||
is_active = False
|
||||
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
await db.commit()
|
||||
|
||||
status_text = "активным" if is_active else "истёкшим"
|
||||
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def _check_expiring_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
warning_days = settings.get_autopay_warning_days()
|
||||
|
||||
for days in warning_days:
|
||||
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
|
||||
|
||||
for subscription in expiring_subscriptions:
|
||||
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
|
||||
|
||||
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} дней")
|
||||
|
||||
if expiring_subscriptions:
|
||||
await self._log_monitoring_event(
|
||||
db, "expiring_notifications_sent",
|
||||
f"Отправлено {len(expiring_subscriptions)} уведомлений об истечении через {days} дней",
|
||||
{"days": days, "count": len(expiring_subscriptions)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истекающих подписок: {e}")
|
||||
|
||||
async def _check_trial_expiring_soon(self, db: AsyncSession):
|
||||
try:
|
||||
threshold_time = datetime.utcnow() + timedelta(hours=2)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.is_trial == True,
|
||||
Subscription.end_date <= threshold_time,
|
||||
Subscription.end_date > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
trial_expiring = result.scalars().all()
|
||||
|
||||
for subscription in trial_expiring:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"trial_2h_{user.telegram_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 часа")
|
||||
|
||||
if trial_expiring:
|
||||
await self._log_monitoring_event(
|
||||
db, "trial_expiring_notifications_sent",
|
||||
f"Отправлено {len(trial_expiring)} уведомлений об окончании тестовых подписок",
|
||||
{"count": len(trial_expiring)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
|
||||
|
||||
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
|
||||
current_time = datetime.utcnow()
|
||||
threshold_date = current_time + timedelta(days=days_before)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.is_trial == False,
|
||||
Subscription.end_date > current_time,
|
||||
Subscription.end_date <= threshold_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
|
||||
logger.info(f"📅 Текущее время: {current_time}")
|
||||
logger.info(f"📅 Пороговая дата: {threshold_date}")
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
|
||||
|
||||
return subscriptions
|
||||
|
||||
async def _process_autopayments(self, db: AsyncSession):
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.autopay_enabled == True,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
all_autopay_subscriptions = result.scalars().all()
|
||||
|
||||
autopay_subscriptions = []
|
||||
for sub in all_autopay_subscriptions:
|
||||
days_before_expiry = (sub.end_date - current_time).days
|
||||
if days_before_expiry <= sub.autopay_days_before:
|
||||
autopay_subscriptions.append(sub)
|
||||
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
renewal_cost = settings.PRICE_30_DAYS
|
||||
|
||||
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
|
||||
if autopay_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if user.balance_kopeks >= renewal_cost:
|
||||
success = await subtract_user_balance(
|
||||
db, user, renewal_cost,
|
||||
"Автопродление подписки"
|
||||
)
|
||||
|
||||
if success:
|
||||
await extend_subscription(db, subscription, 30)
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
if self.bot:
|
||||
await self._send_autopay_success_notification(user, renewal_cost, 30)
|
||||
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
|
||||
else:
|
||||
failed_count += 1
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
|
||||
else:
|
||||
failed_count += 1
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
|
||||
|
||||
if processed_count > 0 or failed_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
db, "autopayments_processed",
|
||||
f"Автоплатежи: успешно {processed_count}, неудачно {failed_count}",
|
||||
{"processed": processed_count, "failed": failed_count}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки автоплатежей: {e}")
|
||||
|
||||
async def _send_subscription_expired_notification(self, user: User):
|
||||
try:
|
||||
message = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
|
||||
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 = "✅ Включен - подписка продлится автоматически"
|
||||
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
|
||||
else:
|
||||
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⏰ Продлить подписку", callback_data="subscription_extend")],
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _send_trial_ending_notification(self, user: User, subscription: Subscription):
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
|
||||
message = f"""
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через 2 часа.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку со скидкой!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
message = texts.AUTOPAY_SUCCESS.format(
|
||||
days=days,
|
||||
amount=settings.format_price(amount)
|
||||
)
|
||||
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _send_autopay_failed_notification(self, user: User, balance: int, required: int):
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
message = texts.AUTOPAY_FAILED.format(
|
||||
balance=settings.format_price(balance),
|
||||
required=settings.format_price(required)
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _cleanup_inactive_users(self, db: AsyncSession):
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
if now.hour != 3:
|
||||
return
|
||||
|
||||
inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS)
|
||||
deleted_count = 0
|
||||
|
||||
for user in inactive_users:
|
||||
if not user.subscription or not user.subscription.is_active:
|
||||
success = await delete_user(db, user)
|
||||
if success:
|
||||
deleted_count += 1
|
||||
|
||||
if deleted_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
db, "inactive_users_cleanup",
|
||||
f"Удалено {deleted_count} неактивных пользователей",
|
||||
{"deleted_count": deleted_count}
|
||||
)
|
||||
logger.info(f"🗑️ Удалено {deleted_count} неактивных пользователей")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки неактивных пользователей: {e}")
|
||||
|
||||
async def _sync_with_remnawave(self, db: AsyncSession):
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
if now.minute != 0:
|
||||
return
|
||||
|
||||
async with self.subscription_service.api as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "remnawave_sync",
|
||||
"Синхронизация с RemnaWave завершена",
|
||||
{"stats": system_stats}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации с RemnaWave: {e}")
|
||||
await self._log_monitoring_event(
|
||||
db, "remnawave_sync_error",
|
||||
f"Ошибка синхронизации с RemnaWave: {str(e)}",
|
||||
{"error": str(e)},
|
||||
is_success=False
|
||||
)
|
||||
|
||||
async def _log_monitoring_event(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: str,
|
||||
message: str,
|
||||
data: Dict[str, Any] = None,
|
||||
is_success: bool = True
|
||||
):
|
||||
try:
|
||||
log_entry = MonitoringLog(
|
||||
event_type=event_type,
|
||||
message=message,
|
||||
data=data or {},
|
||||
is_success=is_success
|
||||
)
|
||||
|
||||
db.add(log_entry)
|
||||
await db.commit()
|
||||
|
||||
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
|
||||
|
||||
recent_events_result = await db.execute(
|
||||
select(MonitoringLog)
|
||||
.order_by(desc(MonitoringLog.created_at))
|
||||
.limit(10)
|
||||
)
|
||||
recent_events = recent_events_result.scalars().all()
|
||||
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
|
||||
events_24h_result = await db.execute(
|
||||
select(MonitoringLog)
|
||||
.where(MonitoringLog.created_at >= yesterday)
|
||||
)
|
||||
events_24h = events_24h_result.scalars().all()
|
||||
|
||||
successful_events = sum(1 for event in events_24h if event.is_success)
|
||||
failed_events = sum(1 for event in events_24h if not event.is_success)
|
||||
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"last_update": datetime.utcnow(),
|
||||
"recent_events": [
|
||||
{
|
||||
"type": event.event_type,
|
||||
"message": event.message,
|
||||
"success": event.is_success,
|
||||
"created_at": event.created_at
|
||||
}
|
||||
for event in recent_events
|
||||
],
|
||||
"stats_24h": {
|
||||
"total_events": len(events_24h),
|
||||
"successful": successful_events,
|
||||
"failed": failed_events,
|
||||
"success_rate": round(successful_events / len(events_24h) * 100, 1) if events_24h else 0
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса мониторинга: {e}")
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"last_update": datetime.utcnow(),
|
||||
"recent_events": [],
|
||||
"stats_24h": {
|
||||
"total_events": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"success_rate": 0
|
||||
}
|
||||
}
|
||||
|
||||
async def force_check_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
expired_count = 0
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
await deactivate_subscription(db, subscription)
|
||||
expired_count += 1
|
||||
|
||||
expiring_subscriptions = await get_expiring_subscriptions(db, 1)
|
||||
expiring_count = len(expiring_subscriptions)
|
||||
|
||||
autopay_subscriptions = await get_subscriptions_for_autopay(db)
|
||||
autopay_processed = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.balance_kopeks >= settings.PRICE_30_DAYS:
|
||||
autopay_processed += 1
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "manual_check_subscriptions",
|
||||
f"Принудительная проверка: истекло {expired_count}, истекает {expiring_count}, автоплатежей {autopay_processed}",
|
||||
{
|
||||
"expired": expired_count,
|
||||
"expiring": expiring_count,
|
||||
"autopay_ready": autopay_processed
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"expired": expired_count,
|
||||
"expiring": expiring_count,
|
||||
"autopay_ready": autopay_processed
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной проверки подписок: {e}")
|
||||
return {"expired": 0, "expiring": 0, "autopay_ready": 0}
|
||||
|
||||
async def get_monitoring_logs(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
limit: int = 50,
|
||||
event_type: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
query = select(MonitoringLog).order_by(desc(MonitoringLog.created_at))
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"event_type": log.event_type,
|
||||
"message": log.message,
|
||||
"data": log.data,
|
||||
"is_success": log.is_success,
|
||||
"created_at": log.created_at
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Удалено {deleted_count} старых записей логов")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
monitoring_service = MonitoringService()
|
||||
@@ -0,0 +1,415 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
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__)
|
||||
|
||||
|
||||
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,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
payload: Optional[str] = None
|
||||
) -> str:
|
||||
|
||||
if not self.bot or not self.stars_service:
|
||||
raise ValueError("Bot instance required for Stars payments")
|
||||
|
||||
try:
|
||||
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=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} звезд (~{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:
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
f"Пополнение Stars: {rubles_amount:.2f}₽ ({stars_amount} ⭐)"
|
||||
)
|
||||
|
||||
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:
|
||||
await add_user_balance(db, user, updated_payment.amount_kopeks, f"Пополнение YooKassa: {updated_payment.amount_kopeks/100:.2f}₽")
|
||||
|
||||
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,
|
||||
user_id: int,
|
||||
description: str
|
||||
) -> str:
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
raise ValueError("Tribute payments are disabled")
|
||||
|
||||
try:
|
||||
payment_data = {
|
||||
"amount": amount_kopeks,
|
||||
"currency": "RUB",
|
||||
"description": description,
|
||||
"user_id": user_id,
|
||||
"callback_url": f"{settings.WEBHOOK_URL}/tribute/callback"
|
||||
}
|
||||
|
||||
payment_url = f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}"
|
||||
|
||||
logger.info(f"Создан Tribute платеж на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
raise
|
||||
|
||||
def verify_tribute_webhook(
|
||||
self,
|
||||
data: dict,
|
||||
signature: str
|
||||
) -> bool:
|
||||
|
||||
if not settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
return False
|
||||
|
||||
try:
|
||||
message = str(data).encode()
|
||||
expected_signature = hmac.new(
|
||||
settings.TRIBUTE_WEBHOOK_SECRET.encode(),
|
||||
message,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки Tribute webhook: {e}")
|
||||
return False
|
||||
|
||||
async def process_successful_payment(
|
||||
self,
|
||||
payment_id: str,
|
||||
amount_kopeks: int,
|
||||
user_id: int,
|
||||
payment_method: str
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
logger.info(f"Обработан успешный платеж: {payment_id}, {amount_kopeks/100}₽, {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки платежа: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,144 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promocode import (
|
||||
get_promocode_by_code, use_promocode, check_user_promocode_usage,
|
||||
create_promocode_use, get_promocode_use_by_user_and_code
|
||||
)
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||||
from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PromoCodeService:
|
||||
|
||||
def __init__(self):
|
||||
self.remnawave_service = RemnaWaveService()
|
||||
self.subscription_service = SubscriptionService()
|
||||
|
||||
async def activate_promocode(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
code: str
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return {"success": False, "error": "user_not_found"}
|
||||
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
if not promocode:
|
||||
return {"success": False, "error": "not_found"}
|
||||
|
||||
if not promocode.is_valid:
|
||||
if promocode.current_uses >= promocode.max_uses:
|
||||
return {"success": False, "error": "used"}
|
||||
else:
|
||||
return {"success": False, "error": "expired"}
|
||||
|
||||
existing_use = await check_user_promocode_usage(db, user_id, promocode.id)
|
||||
if existing_use:
|
||||
return {"success": False, "error": "already_used_by_user"}
|
||||
|
||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||
|
||||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
await mark_user_as_had_paid_subscription(db, user)
|
||||
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
|
||||
|
||||
await create_promocode_use(db, promocode.id, user_id)
|
||||
|
||||
promocode.current_uses += 1
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"description": result_description
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации промокода {code} для пользователя {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return {"success": False, "error": "server_error"}
|
||||
|
||||
async def _apply_promocode_effects(self, db: AsyncSession, user: User, promocode: PromoCode) -> str:
|
||||
effects = []
|
||||
|
||||
if promocode.balance_bonus_kopeks > 0:
|
||||
await add_user_balance(
|
||||
db, user, promocode.balance_bonus_kopeks,
|
||||
f"Бонус по промокоду {promocode.code}"
|
||||
)
|
||||
|
||||
balance_bonus_rubles = promocode.balance_bonus_kopeks / 100
|
||||
effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}₽")
|
||||
|
||||
if promocode.subscription_days > 0:
|
||||
from app.config import settings
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if subscription:
|
||||
await extend_subscription(db, subscription, promocode.subscription_days)
|
||||
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
effects.append(f"⏰ Подписка продлена на {promocode.subscription_days} дней")
|
||||
logger.info(f"✅ Подписка пользователя {user.telegram_id} продлена на {promocode.subscription_days} дней в RemnaWave с текущими сквадами")
|
||||
|
||||
else:
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
|
||||
trial_squads = []
|
||||
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=promocode.subscription_days,
|
||||
traffic_limit_gb=0,
|
||||
device_limit=1,
|
||||
connected_squads=trial_squads
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
||||
|
||||
effects.append(f"🎉 Получена подписка на {promocode.subscription_days} дней")
|
||||
logger.info(f"✅ Создана новая подписка для пользователя {user.telegram_id} на {promocode.subscription_days} дней с триал сквадом {trial_squads}")
|
||||
|
||||
if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value:
|
||||
from app.database.crud.subscription import create_trial_subscription
|
||||
from app.config import settings
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not subscription:
|
||||
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
|
||||
|
||||
trial_subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
effects.append(f"🎁 Активирована тестовая подписка на {trial_days} дней")
|
||||
logger.info(f"✅ Создана триал подписка для пользователя {user.telegram_id} на {trial_days} дней")
|
||||
else:
|
||||
effects.append("ℹ️ У вас уже есть активная подписка")
|
||||
|
||||
return "\n".join(effects) if effects else "✅ Промокод активирован"
|
||||
@@ -0,0 +1,186 @@
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def process_referral_registration(
|
||||
db: AsyncSession,
|
||||
new_user_id: int,
|
||||
referrer_id: int
|
||||
):
|
||||
|
||||
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={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,
|
||||
referral_id=new_user_id,
|
||||
amount_kopeks=0,
|
||||
reason="referral_registration_pending"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработана реферальная регистрация: {new_user_id} -> {referrer_id}")
|
||||
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
|
||||
):
|
||||
|
||||
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
|
||||
|
||||
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 and settings.REFERRAL_REGISTRATION_REWARD > 0:
|
||||
reward_amount = settings.REFERRAL_REGISTRATION_REWARD
|
||||
|
||||
if reward_amount > 1000000:
|
||||
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: reward_amount = {reward_amount} слишком большой! Проверьте настройки REFERRAL_REGISTRATION_REWARD")
|
||||
reward_amount = 10000
|
||||
|
||||
await add_user_balance(
|
||||
db, referrer, reward_amount,
|
||||
f"Реферальная награда за первую покупку {user.full_name}"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
logger.info(f"🎉 Первая покупка реферала: {referrer.telegram_id} получил {reward_amount/100}₽")
|
||||
|
||||
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
|
||||
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный! Должен быть от 0 до 100")
|
||||
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"Комиссия {commission_percent}% с покупки {user.full_name}"
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission",
|
||||
referral_transaction_id=transaction_id
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с покупки: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
await db.commit()
|
||||
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
|
||||
|
||||
return True
|
||||
|
||||
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() or 0
|
||||
|
||||
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() or 0
|
||||
|
||||
total_earned = await get_referral_earnings_sum(db, user_id) or 0
|
||||
|
||||
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) or 0
|
||||
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, SubscriptionStatus
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
|
||||
def __init__(self):
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY
|
||||
)
|
||||
|
||||
async def create_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {subscription.user_id} не найден")
|
||||
return None
|
||||
|
||||
validation_success = await self.validate_and_clean_subscription(db, subscription, user)
|
||||
if not validation_success:
|
||||
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
|
||||
remnawave_user = existing_users[0]
|
||||
|
||||
try:
|
||||
await api.reset_user_devices(remnawave_user.uuid)
|
||||
logger.info(f"🔧 Сброшены HWID устройства для пользователя {user.telegram_id}")
|
||||
except Exception as hwid_error:
|
||||
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
|
||||
|
||||
updated_user = await api.update_user(
|
||||
uuid=remnawave_user.uuid,
|
||||
status=UserStatus.ACTIVE,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = f"user_{user.telegram_id}"
|
||||
updated_user = await api.create_user(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
user.remnawave_uuid = updated_user.uuid
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
|
||||
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
|
||||
logger.info(f"📊 Стратегия сброса трафика: MONTH")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def update_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
|
||||
return None
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date > current_time)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
is_actually_active = False
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
await db.commit()
|
||||
|
||||
status_text = "активным" if is_actually_active else "истёкшим"
|
||||
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
|
||||
logger.info(f"📊 Стратегия сброса трафика: MONTH")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
await api.disable_user(user_uuid)
|
||||
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отключения RemnaWave пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def revoke_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[str]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}")
|
||||
return updated_user.subscription_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления ссылки подписки: {e}")
|
||||
return None
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
info = await api.get_subscription_info(short_uuid)
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о подписке: {e}")
|
||||
return None
|
||||
|
||||
async def sync_subscription_usage(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
return False
|
||||
|
||||
async with self.api as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if not remnawave_user:
|
||||
return False
|
||||
|
||||
used_gb = self._bytes_to_gb(remnawave_user.used_traffic_bytes)
|
||||
subscription.traffic_used_gb = used_gb
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации трафика: {e}")
|
||||
return False
|
||||
|
||||
async def calculate_subscription_price(
|
||||
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}")
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 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}₽")
|
||||
else:
|
||||
server_prices.append(0)
|
||||
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}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
total_price = base_price + servers_price + devices_price + traffic_price
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}₽")
|
||||
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}₽")
|
||||
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета стоимости продления: {e}")
|
||||
from app.config import PERIOD_PRICES
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
async def validate_and_clean_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
user: User
|
||||
) -> bool:
|
||||
try:
|
||||
needs_cleanup = False
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
if not remnawave_user:
|
||||
logger.warning(f"⚠️ Пользователь {user.telegram_id} имеет UUID {user.remnawave_uuid}, но не найден в панели")
|
||||
needs_cleanup = True
|
||||
else:
|
||||
if remnawave_user.telegram_id != user.telegram_id:
|
||||
logger.warning(f"⚠️ Несоответствие telegram_id для пользователя {user.telegram_id}")
|
||||
needs_cleanup = True
|
||||
except Exception as api_error:
|
||||
logger.error(f"❌ Ошибка проверки пользователя в панели: {api_error}")
|
||||
needs_cleanup = True
|
||||
|
||||
if subscription.remnawave_short_uuid and not user.remnawave_uuid:
|
||||
logger.warning(f"⚠️ У подписки есть short_uuid, но у пользователя нет remnawave_uuid")
|
||||
needs_cleanup = True
|
||||
|
||||
if needs_cleanup:
|
||||
logger.info(f"🧹 Очищаем мусорные данные подписки для пользователя {user.telegram_id}")
|
||||
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.subscription_url = ""
|
||||
subscription.connected_squads = []
|
||||
|
||||
user.remnawave_uuid = None
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Мусорные данные очищены для пользователя {user.telegram_id}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка валидации подписки для пользователя {user.telegram_id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def get_countries_price_by_uuids(
|
||||
self,
|
||||
country_uuids: List[str],
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, List[int]]:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
|
||||
total_price = 0
|
||||
prices_list = []
|
||||
|
||||
for country_uuid in country_uuids:
|
||||
server = await get_server_squad_by_uuid(db, country_uuid)
|
||||
if server and server.is_available and not server.is_full:
|
||||
price = server.price_kopeks
|
||||
total_price += price
|
||||
prices_list.append(price)
|
||||
logger.debug(f"🏷️ Страна {server.display_name}: {price/100}₽")
|
||||
else:
|
||||
default_price = 0
|
||||
total_price += default_price
|
||||
prices_list.append(default_price)
|
||||
logger.warning(f"⚠️ Сервер {country_uuid} недоступен, используем базовую цену: {default_price/100}₽")
|
||||
|
||||
logger.info(f"💰 Общая стоимость стран: {total_price/100}₽")
|
||||
return total_price, prices_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения цен стран: {e}")
|
||||
default_prices = [0] * len(country_uuids)
|
||||
return sum(default_prices), default_prices
|
||||
|
||||
async def _get_countries_price(self, country_uuids: List[str], db: AsyncSession) -> int:
|
||||
try:
|
||||
total_price, _ = await self.get_countries_price_by_uuids(country_uuids, db)
|
||||
return total_price
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения цен стран: {e}")
|
||||
return len(country_uuids) * 1000
|
||||
|
||||
def _gb_to_bytes(self, gb: int) -> int:
|
||||
if gb == 0:
|
||||
return 0
|
||||
return gb * 1024 * 1024 * 1024
|
||||
|
||||
def _bytes_to_gb(self, bytes_value: int) -> float:
|
||||
if bytes_value == 0:
|
||||
return 0.0
|
||||
return bytes_value / (1024 * 1024 * 1024)
|
||||
@@ -0,0 +1,349 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.models import Transaction, TransactionType, PaymentMethod
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction, get_transaction_by_external_id, complete_transaction
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TributeService:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.tribute_api = TributeAPI()
|
||||
|
||||
async def create_payment_link(
|
||||
self,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> Optional[str]:
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
logger.warning("Tribute платежи отключены")
|
||||
return None
|
||||
|
||||
try:
|
||||
payment_url = await self.tribute_api.create_payment_link(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
return None
|
||||
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
return None
|
||||
|
||||
async def process_webhook(
|
||||
self,
|
||||
payload: str,
|
||||
signature: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
if not self.tribute_api.verify_webhook_signature(payload, signature):
|
||||
logger.warning("Неверная подпись Tribute webhook")
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON в Tribute webhook")
|
||||
return {"status": "error", "reason": "invalid_json"}
|
||||
|
||||
logger.info(f"Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}")
|
||||
|
||||
processed_data = await self.tribute_api.process_webhook(webhook_data)
|
||||
if not processed_data:
|
||||
return {"status": "ignored", "reason": "invalid_data"}
|
||||
|
||||
event_type = processed_data.get("event_type", "payment")
|
||||
status = processed_data.get("status")
|
||||
|
||||
if event_type == "payment" and status == "paid":
|
||||
await self._handle_successful_payment(processed_data)
|
||||
elif event_type == "payment" and status == "failed":
|
||||
await self._handle_failed_payment(processed_data)
|
||||
elif event_type == "refund":
|
||||
await self._handle_refund(processed_data)
|
||||
|
||||
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():
|
||||
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 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)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден")
|
||||
return
|
||||
|
||||
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
|
||||
|
||||
transaction = await create_unique_tribute_transaction(
|
||||
db=session,
|
||||
user_id=user.id,
|
||||
payment_id=payment_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
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}", exc_info=True)
|
||||
|
||||
async def _handle_failed_payment(self, payment_data: Dict[str, Any]):
|
||||
"""Обработка неудачного платежа"""
|
||||
|
||||
try:
|
||||
user_id = payment_data["user_id"]
|
||||
payment_id = payment_data["payment_id"]
|
||||
|
||||
async for session in get_db():
|
||||
transaction = await get_transaction_by_external_id(
|
||||
session, f"donation_{payment_id}", PaymentMethod.TRIBUTE
|
||||
)
|
||||
|
||||
if transaction:
|
||||
transaction.description = f"{transaction.description} (платеж отклонен)"
|
||||
await session.commit()
|
||||
|
||||
await self._send_failure_notification(user_id)
|
||||
|
||||
logger.info(f"Обработан неудачный Tribute платеж для пользователя {user_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
|
||||
|
||||
async def _handle_refund(self, refund_data: Dict[str, Any]):
|
||||
"""Обработка возврата"""
|
||||
|
||||
try:
|
||||
user_id = refund_data["user_id"]
|
||||
amount_kopeks = refund_data["amount_kopeks"]
|
||||
payment_id = refund_data["payment_id"]
|
||||
|
||||
async for session in get_db():
|
||||
await create_transaction(
|
||||
db=session,
|
||||
user_id=user_id,
|
||||
type=TransactionType.REFUND,
|
||||
amount_kopeks=-amount_kopeks,
|
||||
description=f"Возврат Tribute платежа {payment_id}",
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=f"refund_{payment_id}",
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if user and user.balance_kopeks >= amount_kopeks:
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
await session.commit()
|
||||
|
||||
await self._send_refund_notification(user_id, amount_kopeks)
|
||||
|
||||
logger.info(f"Обработан возврат Tribute: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки возврата Tribute: {e}")
|
||||
|
||||
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
|
||||
"""Отправка уведомления об успешном платеже"""
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
text = (
|
||||
f"✅ **Платеж успешно получен!**\n\n"
|
||||
f"💰 Сумма: {amount_rubles:.2f} ₽\n"
|
||||
f"💳 Способ оплаты: Tribute\n"
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"Спасибо за оплату! 🙏"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||||
|
||||
async def _send_failure_notification(self, user_id: int):
|
||||
"""Отправка уведомления о неудачном платеже"""
|
||||
|
||||
try:
|
||||
text = (
|
||||
"❌ **Платеж не прошел**\n\n"
|
||||
"К сожалению, ваш платеж через Tribute был отклонен.\n\n"
|
||||
"Возможные причины:\n"
|
||||
"• Недостаточно средств на карте\n"
|
||||
"• Технические проблемы банка\n"
|
||||
"• Превышен лимит операций\n\n"
|
||||
"Попробуйте еще раз или обратитесь в поддержку."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
|
||||
|
||||
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
|
||||
"""Отправка уведомления о возврате"""
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
text = (
|
||||
f"🔄 **Возврат средств**\n\n"
|
||||
f"💰 Сумма возврата: {amount_rubles:.2f} ₽\n"
|
||||
f"💳 Способ: Tribute\n\n"
|
||||
f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n"
|
||||
f"Если у вас есть вопросы, обратитесь в поддержку."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
async def create_refund(
|
||||
self,
|
||||
payment_id: str,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
reason: str = "Возврат по запросу"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
|
||||
@@ -0,0 +1,498 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user
|
||||
)
|
||||
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, YooKassaPayment, BroadcastHistory
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserService:
|
||||
|
||||
async def get_user_profile(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
transactions_count = await get_user_transactions_count(db, user_id)
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"subscription": subscription,
|
||||
"transactions_count": transactions_count,
|
||||
"is_admin": settings.is_admin(user.telegram_id),
|
||||
"registration_days": (datetime.utcnow() - user.created_at).days
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения профиля пользователя {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def search_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
page: int = 1,
|
||||
limit: int = 20
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, search=query
|
||||
)
|
||||
total_count = await get_users_count(db, search=query)
|
||||
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"total_count": total_count,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка поиска пользователей: {e}")
|
||||
return {
|
||||
"users": [],
|
||||
"current_page": 1,
|
||||
"total_pages": 1,
|
||||
"total_count": 0,
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
async def get_users_page(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
status: Optional[UserStatus] = None
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, status=status
|
||||
)
|
||||
total_count = await get_users_count(db, status=status)
|
||||
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"total_count": total_count,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения страницы пользователей: {e}")
|
||||
return {
|
||||
"users": [],
|
||||
"current_page": 1,
|
||||
"total_pages": 1,
|
||||
"total_count": 0,
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
async def update_user_balance(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
admin_id: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if amount_kopeks > 0:
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int,
|
||||
reason: str = "Заблокирован администратором"
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован при блокировке")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка деактивации RemnaWave пользователя при блокировке: {e}")
|
||||
|
||||
if user.subscription:
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
await deactivate_subscription(db, user.subscription)
|
||||
|
||||
await update_user(db, user, status=UserStatus.BLOCKED.value)
|
||||
|
||||
logger.info(f"Админ {admin_id} заблокировал пользователя {user_id}: {reason}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка блокировки пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def unblock_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
await update_user(db, user, status=UserStatus.ACTIVE.value)
|
||||
|
||||
if user.subscription:
|
||||
from datetime import datetime
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
if user.subscription.end_date > datetime.utcnow():
|
||||
user.subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
await db.commit()
|
||||
await db.refresh(user.subscription)
|
||||
logger.info(f"🔄 Подписка пользователя {user_id} восстановлена")
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} восстановлен при разблокировке")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка восстановления RemnaWave пользователя при разблокировке: {e}")
|
||||
else:
|
||||
logger.info(f"⏰ Подписка пользователя {user_id} истекла, восстановление невозможно")
|
||||
|
||||
logger.info(f"Админ {admin_id} разблокировал пользователя {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка разблокировки пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def delete_user_account(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.warning(f"Пользователь {user_id} не найден для удаления")
|
||||
return False
|
||||
|
||||
logger.info(f"🗑️ Начинаем полное удаление пользователя {user_id} (Telegram ID: {user.telegram_id})")
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f" ✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
|
||||
except Exception as e:
|
||||
logger.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}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
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:
|
||||
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:
|
||||
from sqlalchemy import update
|
||||
referrals_result = await db.execute(
|
||||
update(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
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(User).where(User.id == user_id)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f" ✅ Пользователь {user_id} окончательно удален из базы")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка финального удаления пользователя: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} (ID: {user_id}) полностью удален администратором {admin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка удаления пользователя {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def get_user_statistics(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
try:
|
||||
stats = await get_users_statistics(db)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики пользователей: {e}")
|
||||
return {
|
||||
"total_users": 0,
|
||||
"active_users": 0,
|
||||
"blocked_users": 0,
|
||||
"new_today": 0,
|
||||
"new_week": 0,
|
||||
"new_month": 0
|
||||
}
|
||||
|
||||
async def cleanup_inactive_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
months: int = None
|
||||
) -> int:
|
||||
try:
|
||||
if months is None:
|
||||
months = settings.INACTIVE_USER_DELETE_MONTHS
|
||||
|
||||
inactive_users = await get_inactive_users(db, months)
|
||||
deleted_count = 0
|
||||
|
||||
for user in inactive_users:
|
||||
success = await self.delete_user_account(db, user.id, 0)
|
||||
if success:
|
||||
deleted_count += 1
|
||||
|
||||
logger.info(f"Удалено {deleted_count} неактивных пользователей")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки неактивных пользователей: {e}")
|
||||
return 0
|
||||
|
||||
async def get_user_activity_summary(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
transactions_count = await get_user_transactions_count(db, user_id)
|
||||
|
||||
days_since_registration = (datetime.utcnow() - user.created_at).days
|
||||
|
||||
days_since_activity = (datetime.utcnow() - user.last_activity).days if user.last_activity else None
|
||||
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"full_name": user.full_name,
|
||||
"status": user.status,
|
||||
"language": user.language,
|
||||
"balance_kopeks": user.balance_kopeks,
|
||||
"registration_date": user.created_at,
|
||||
"last_activity": user.last_activity,
|
||||
"days_since_registration": days_since_registration,
|
||||
"days_since_activity": days_since_activity,
|
||||
"has_subscription": subscription is not None,
|
||||
"subscription_active": subscription.is_active if subscription else False,
|
||||
"subscription_trial": subscription.is_trial if subscription else False,
|
||||
"transactions_count": transactions_count,
|
||||
"referrer_id": user.referred_by_id,
|
||||
"referral_code": user.referral_code
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сводки активности пользователя {user_id}: {e}")
|
||||
return {}
|
||||
|
||||
async def get_users_by_criteria(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
criteria: Dict[str, Any]
|
||||
) -> List[User]:
|
||||
try:
|
||||
status = criteria.get('status')
|
||||
has_subscription = criteria.get('has_subscription')
|
||||
is_trial = criteria.get('is_trial')
|
||||
min_balance = criteria.get('min_balance', 0)
|
||||
max_balance = criteria.get('max_balance')
|
||||
days_inactive = criteria.get('days_inactive')
|
||||
|
||||
registered_after = criteria.get('registered_after')
|
||||
registered_before = criteria.get('registered_before')
|
||||
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=status)
|
||||
|
||||
filtered_users = []
|
||||
for user in users:
|
||||
if user.balance_kopeks < min_balance:
|
||||
continue
|
||||
if max_balance and user.balance_kopeks > max_balance:
|
||||
continue
|
||||
|
||||
if registered_after and user.created_at < registered_after:
|
||||
continue
|
||||
if registered_before and user.created_at > registered_before:
|
||||
continue
|
||||
|
||||
if days_inactive and user.last_activity:
|
||||
inactive_threshold = datetime.utcnow() - timedelta(days=days_inactive)
|
||||
if user.last_activity > inactive_threshold:
|
||||
continue
|
||||
|
||||
filtered_users.append(user)
|
||||
|
||||
return filtered_users
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения пользователей по критериям: {e}")
|
||||
return []
|
||||
@@ -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
|
||||
@@ -0,0 +1,85 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
waiting_for_rules_accept = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class SubscriptionStates(StatesGroup):
|
||||
selecting_period = State()
|
||||
selecting_traffic = State()
|
||||
selecting_countries = State()
|
||||
selecting_devices = State()
|
||||
confirming_purchase = State()
|
||||
|
||||
adding_countries = State()
|
||||
adding_traffic = State()
|
||||
adding_devices = State()
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
|
||||
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
waiting_for_stars_payment = State()
|
||||
waiting_for_support_request = State()
|
||||
|
||||
|
||||
class PromoCodeStates(StatesGroup):
|
||||
waiting_for_code = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class AdminStates(StatesGroup):
|
||||
|
||||
waiting_for_user_search = State()
|
||||
editing_user_balance = State()
|
||||
extending_subscription = State()
|
||||
adding_traffic = State()
|
||||
granting_subscription = State()
|
||||
editing_user_subscription = State()
|
||||
|
||||
creating_promocode = State()
|
||||
setting_promocode_type = State()
|
||||
setting_promocode_value = State()
|
||||
setting_promocode_uses = State()
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
editing_device_price = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
|
||||
confirming_sync = State()
|
||||
|
||||
editing_server_name = State()
|
||||
editing_server_price = State()
|
||||
editing_server_country = State()
|
||||
editing_server_limit = State()
|
||||
editing_server_description = State()
|
||||
|
||||
creating_server_uuid = State()
|
||||
creating_server_name = State()
|
||||
creating_server_price = State()
|
||||
creating_server_country = State()
|
||||
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
|
||||
class SquadCreateStates(StatesGroup):
|
||||
waiting_for_name = State()
|
||||
selecting_inbounds = State()
|
||||
|
||||
class SquadRenameStates(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Утилиты
|
||||
"""
|
||||
@@ -0,0 +1,264 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional, Union
|
||||
from datetime import datetime, timedelta
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheService:
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
self._connected = False
|
||||
|
||||
async def connect(self):
|
||||
try:
|
||||
self.redis_client = redis.from_url(settings.REDIS_URL)
|
||||
await self.redis_client.ping()
|
||||
self._connected = True
|
||||
logger.info("✅ Подключение к Redis кешу установлено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}")
|
||||
self._connected = False
|
||||
|
||||
async def disconnect(self):
|
||||
if self.redis_client:
|
||||
await self.redis_client.close()
|
||||
self._connected = False
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self.redis_client.get(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения из кеша {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
expire: Union[int, timedelta] = None
|
||||
) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized_value = json.dumps(value, default=str)
|
||||
|
||||
if isinstance(expire, timedelta):
|
||||
expire = int(expire.total_seconds())
|
||||
|
||||
await self.redis_client.set(key, serialized_value, ex=expire)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка записи в кеш {key}: {e}")
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
deleted = await self.redis_client.delete(key)
|
||||
return deleted > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления из кеша {key}: {e}")
|
||||
return False
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self.redis_client.exists(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки существования в кеше {key}: {e}")
|
||||
return False
|
||||
|
||||
async def expire(self, key: str, seconds: int) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self.redis_client.expire(key, seconds)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка установки TTL для {key}: {e}")
|
||||
return False
|
||||
|
||||
async def get_keys(self, pattern: str = "*") -> list:
|
||||
if not self._connected:
|
||||
return []
|
||||
|
||||
try:
|
||||
keys = await self.redis_client.keys(pattern)
|
||||
return [key.decode() if isinstance(key, bytes) else key for key in keys]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения ключей по паттерну {pattern}: {e}")
|
||||
return []
|
||||
|
||||
async def flush_all(self) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.redis_client.flushall()
|
||||
logger.info("🗑️ Кеш полностью очищен")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки кеша: {e}")
|
||||
return False
|
||||
|
||||
async def increment(self, key: str, amount: int = 1) -> Optional[int]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await self.redis_client.incrby(key, amount)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка инкремента {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set_hash(self, name: str, mapping: dict, expire: int = None) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.redis_client.hset(name, mapping=mapping)
|
||||
if expire:
|
||||
await self.redis_client.expire(name, expire)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка записи хеша {name}: {e}")
|
||||
return False
|
||||
|
||||
async def get_hash(self, name: str, key: str = None) -> Optional[Union[dict, str]]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
if key:
|
||||
value = await self.redis_client.hget(name, key)
|
||||
return value.decode() if value else None
|
||||
else:
|
||||
hash_data = await self.redis_client.hgetall(name)
|
||||
return {k.decode(): v.decode() for k, v in hash_data.items()}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения хеша {name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
cache = CacheService()
|
||||
|
||||
|
||||
def cache_key(*parts) -> str:
|
||||
return ":".join(str(part) for part in parts)
|
||||
|
||||
|
||||
async def cached_function(key: str, expire: int = 300):
|
||||
def decorator(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
cache_result = await cache.get(key)
|
||||
if cache_result is not None:
|
||||
return cache_result
|
||||
|
||||
result = await func(*args, **kwargs)
|
||||
await cache.set(key, result, expire)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class UserCache:
|
||||
|
||||
@staticmethod
|
||||
async def get_user_data(user_id: int) -> Optional[dict]:
|
||||
key = cache_key("user", user_id)
|
||||
return await cache.get(key)
|
||||
|
||||
@staticmethod
|
||||
async def set_user_data(user_id: int, data: dict, expire: int = 3600) -> bool:
|
||||
key = cache_key("user", user_id)
|
||||
return await cache.set(key, data, expire)
|
||||
|
||||
@staticmethod
|
||||
async def delete_user_data(user_id: int) -> bool:
|
||||
key = cache_key("user", user_id)
|
||||
return await cache.delete(key)
|
||||
|
||||
@staticmethod
|
||||
async def get_user_session(user_id: int, session_key: str) -> Optional[Any]:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.get(key)
|
||||
|
||||
@staticmethod
|
||||
async def set_user_session(
|
||||
user_id: int,
|
||||
session_key: str,
|
||||
data: Any,
|
||||
expire: int = 1800
|
||||
) -> bool:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.set(key, data, expire)
|
||||
|
||||
|
||||
class SystemCache:
|
||||
|
||||
@staticmethod
|
||||
async def get_system_stats() -> Optional[dict]:
|
||||
return await cache.get("system:stats")
|
||||
|
||||
@staticmethod
|
||||
async def set_system_stats(stats: dict, expire: int = 300) -> bool:
|
||||
return await cache.set("system:stats", stats, expire)
|
||||
|
||||
@staticmethod
|
||||
async def get_nodes_status() -> Optional[list]:
|
||||
return await cache.get("remnawave:nodes")
|
||||
|
||||
@staticmethod
|
||||
async def set_nodes_status(nodes: list, expire: int = 60) -> bool:
|
||||
return await cache.set("remnawave:nodes", nodes, expire)
|
||||
|
||||
@staticmethod
|
||||
async def get_daily_stats(date: str) -> Optional[dict]:
|
||||
key = cache_key("stats", "daily", date)
|
||||
return await cache.get(key)
|
||||
|
||||
@staticmethod
|
||||
async def set_daily_stats(date: str, stats: dict) -> bool:
|
||||
key = cache_key("stats", "daily", date)
|
||||
return await cache.set(key, stats, 86400) # 24 часа
|
||||
|
||||
|
||||
class RateLimitCache:
|
||||
|
||||
@staticmethod
|
||||
async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool:
|
||||
key = cache_key("rate_limit", user_id, action)
|
||||
current = await cache.get(key)
|
||||
|
||||
if current is None:
|
||||
await cache.set(key, 1, window)
|
||||
return False
|
||||
|
||||
if current >= limit:
|
||||
return True
|
||||
|
||||
await cache.increment(key)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def reset_rate_limit(user_id: int, action: str) -> bool:
|
||||
key = cache_key("rate_limit", user_id, action)
|
||||
return await cache.delete(key)
|
||||
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
import functools
|
||||
from typing import Callable, Any
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def admin_required(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
user = None
|
||||
if isinstance(event, (types.Message, types.CallbackQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user or not settings.is_admin(user.id):
|
||||
texts = get_texts()
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ACCESS_DENIED)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
|
||||
logger.warning(f"Попытка доступа к админской функции от {user.id if user else 'Unknown'}")
|
||||
return
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def error_handler(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в {func.__name__}: {e}", exc_info=True)
|
||||
|
||||
event = None
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, (types.Message, types.CallbackQuery)):
|
||||
event = arg
|
||||
break
|
||||
|
||||
if event:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ERROR)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ERROR, show_alert=True)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def state_cleanup(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
state = kwargs.get('state')
|
||||
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if state and isinstance(state, FSMContext):
|
||||
await state.clear()
|
||||
raise e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def typing_action(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
if isinstance(event, types.Message):
|
||||
await event.bot.send_chat_action(
|
||||
chat_id=event.chat.id,
|
||||
action="typing"
|
||||
)
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def rate_limit(rate: float = 1.0, key: str = None):
|
||||
def decorator(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,218 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Union, Optional
|
||||
|
||||
|
||||
def format_datetime(dt: Union[datetime, str], format_str: str = "%d.%m.%Y %H:%M") -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
dt = datetime.now()
|
||||
|
||||
return dt.strftime(format_str)
|
||||
|
||||
|
||||
def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
dt = datetime.now()
|
||||
|
||||
return dt.strftime(format_str)
|
||||
|
||||
|
||||
def format_time_ago(dt: Union[datetime, str]) -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
dt = datetime.now()
|
||||
|
||||
now = datetime.utcnow()
|
||||
diff = now - dt
|
||||
|
||||
if diff.days > 0:
|
||||
if diff.days == 1:
|
||||
return "вчера"
|
||||
elif diff.days < 7:
|
||||
return f"{diff.days} дн. назад"
|
||||
elif diff.days < 30:
|
||||
weeks = diff.days // 7
|
||||
return f"{weeks} нед. назад"
|
||||
elif diff.days < 365:
|
||||
months = diff.days // 30
|
||||
return f"{months} мес. назад"
|
||||
else:
|
||||
years = diff.days // 365
|
||||
return f"{years} г. назад"
|
||||
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} ч. назад"
|
||||
|
||||
elif diff.seconds > 60:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} мин. назад"
|
||||
|
||||
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:
|
||||
return f"{seconds} сек."
|
||||
|
||||
minutes = seconds // 60
|
||||
if minutes < 60:
|
||||
return f"{minutes} мин."
|
||||
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return f"{hours} ч."
|
||||
|
||||
days = hours // 24
|
||||
return f"{days} дн."
|
||||
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
if bytes_value == 0:
|
||||
return "0 B"
|
||||
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = float(bytes_value)
|
||||
unit_index = 0
|
||||
|
||||
while size >= 1024 and unit_index < len(units) - 1:
|
||||
size /= 1024
|
||||
unit_index += 1
|
||||
|
||||
if size == int(size):
|
||||
return f"{int(size)} {units[unit_index]}"
|
||||
else:
|
||||
return f"{size:.1f} {units[unit_index]}"
|
||||
|
||||
|
||||
def format_percentage(value: float, decimals: int = 1) -> str:
|
||||
return f"{value:.{decimals}f}%"
|
||||
|
||||
|
||||
def format_number(number: Union[int, float], separator: str = " ") -> str:
|
||||
if isinstance(number, float):
|
||||
integer_part = int(number)
|
||||
decimal_part = number - integer_part
|
||||
|
||||
formatted_integer = f"{integer_part:,}".replace(",", separator)
|
||||
|
||||
if decimal_part > 0:
|
||||
return f"{formatted_integer}.{decimal_part:.2f}".split('.')[0] + f".{str(decimal_part).split('.')[1][:2]}"
|
||||
else:
|
||||
return formatted_integer
|
||||
else:
|
||||
return f"{number:,}".replace(",", separator)
|
||||
|
||||
|
||||
def format_price_range(min_price: int, max_price: int) -> str:
|
||||
from app.config import settings
|
||||
|
||||
min_formatted = settings.format_price(min_price)
|
||||
max_formatted = settings.format_price(max_price)
|
||||
|
||||
if min_price == max_price:
|
||||
return min_formatted
|
||||
else:
|
||||
return f"{min_formatted} - {max_formatted}"
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
|
||||
def format_username(username: Optional[str], user_id: int, full_name: Optional[str] = None) -> str:
|
||||
if full_name:
|
||||
return full_name
|
||||
elif username:
|
||||
return f"@{username}"
|
||||
else:
|
||||
return f"ID{user_id}"
|
||||
|
||||
|
||||
def format_subscription_status(
|
||||
is_active: bool,
|
||||
is_trial: bool,
|
||||
end_date: Union[datetime, str],
|
||||
language: str = "ru"
|
||||
) -> str:
|
||||
|
||||
if isinstance(end_date, str):
|
||||
try:
|
||||
end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
end_date = datetime.now()
|
||||
|
||||
if not is_active:
|
||||
return "❌ Неактивна" if language == "ru" else "❌ Inactive"
|
||||
|
||||
if is_trial:
|
||||
status = "🎁 Тестовая" if language == "ru" else "🎁 Trial"
|
||||
else:
|
||||
status = "✅ Активна" if language == "ru" else "✅ Active"
|
||||
|
||||
now = datetime.utcnow()
|
||||
if end_date > now:
|
||||
days_left = (end_date - now).days
|
||||
if days_left > 0:
|
||||
status += f" ({days_left} дн.)" if language == "ru" else f" ({days_left} days)"
|
||||
else:
|
||||
hours_left = (end_date - now).seconds // 3600
|
||||
status += f" ({hours_left} ч.)" if language == "ru" else f" ({hours_left} hrs)"
|
||||
else:
|
||||
status = "⏰ Истекла" if language == "ru" else "⏰ Expired"
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def format_traffic_usage(used_gb: float, limit_gb: int, language: str = "ru") -> str:
|
||||
|
||||
if limit_gb == 0:
|
||||
if language == "ru":
|
||||
return f"{used_gb:.1f} ГБ / ∞"
|
||||
else:
|
||||
return f"{used_gb:.1f} GB / ∞"
|
||||
|
||||
percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0
|
||||
|
||||
if language == "ru":
|
||||
return f"{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)"
|
||||
else:
|
||||
return f"{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)"
|
||||
|
||||
|
||||
def format_boolean(value: bool, language: str = "ru") -> str:
|
||||
if language == "ru":
|
||||
return "✅ Да" if value else "❌ Нет"
|
||||
else:
|
||||
return "✅ Yes" if value else "❌ No"
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import List, TypeVar, Generic, Dict, Any
|
||||
from math import ceil
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class PaginationResult(Generic[T]):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: List[T],
|
||||
total_count: int,
|
||||
page: int,
|
||||
per_page: int
|
||||
):
|
||||
self.items = items
|
||||
self.total_count = total_count
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
self.total_pages = ceil(total_count / per_page) if per_page > 0 else 1
|
||||
self.has_prev = page > 1
|
||||
self.has_next = page < self.total_pages
|
||||
self.prev_page = page - 1 if self.has_prev else None
|
||||
self.next_page = page + 1 if self.has_next else None
|
||||
|
||||
|
||||
def paginate_list(
|
||||
items: List[T],
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> PaginationResult[T]:
|
||||
total_count = len(items)
|
||||
|
||||
start_index = (page - 1) * per_page
|
||||
end_index = start_index + per_page
|
||||
|
||||
page_items = items[start_index:end_index]
|
||||
|
||||
return PaginationResult(
|
||||
items=page_items,
|
||||
total_count=total_count,
|
||||
page=page,
|
||||
per_page=per_page
|
||||
)
|
||||
|
||||
|
||||
def get_pagination_info(
|
||||
total_count: int,
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
total_pages = ceil(total_count / per_page) if per_page > 0 else 1
|
||||
|
||||
return {
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"has_prev": page > 1,
|
||||
"has_next": page < total_pages,
|
||||
"prev_page": page - 1 if page > 1 else None,
|
||||
"next_page": page + 1 if page < total_pages else None,
|
||||
"offset": (page - 1) * per_page
|
||||
}
|
||||
|
||||
|
||||
def get_page_numbers(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
max_visible: int = 5
|
||||
) -> List[int]:
|
||||
if total_pages <= max_visible:
|
||||
return list(range(1, total_pages + 1))
|
||||
|
||||
half_visible = max_visible // 2
|
||||
start_page = max(1, current_page - half_visible)
|
||||
end_page = min(total_pages, start_page + max_visible - 1)
|
||||
|
||||
if end_page - start_page + 1 < max_visible:
|
||||
start_page = max(1, end_page - max_visible + 1)
|
||||
|
||||
return list(range(start_page, end_page + 1))
|
||||
@@ -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
|
||||
@@ -0,0 +1,76 @@
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database.models import User
|
||||
|
||||
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:
|
||||
|
||||
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
|
||||
|
||||
result = await db.execute(
|
||||
select(User.id).where(User.referral_code == referral_code)
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
stats = await get_referral_stats_for_user(db, user_id)
|
||||
|
||||
recent_earnings = await get_referral_earnings_by_user(db, user_id, limit=5)
|
||||
|
||||
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
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сводки рефералов: {e}")
|
||||
return {
|
||||
"invited_count": 0,
|
||||
"paid_referrals_count": 0,
|
||||
"total_earned_kopeks": 0,
|
||||
"month_earned_kopeks": 0,
|
||||
"recent_earnings": []
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
|
||||
|
||||
def validate_phone(phone: str) -> bool:
|
||||
pattern = r'^\+?[1-9]\d{1,14}$'
|
||||
cleaned_phone = re.sub(r'[\s\-\(\)]', '', phone)
|
||||
return re.match(pattern, cleaned_phone) is not None
|
||||
|
||||
|
||||
def validate_telegram_username(username: str) -> bool:
|
||||
if not username:
|
||||
return False
|
||||
username = username.lstrip('@')
|
||||
pattern = r'^[a-zA-Z0-9_]{5,32}$'
|
||||
return re.match(pattern, username) is not None
|
||||
|
||||
|
||||
def validate_promocode(code: str) -> bool:
|
||||
if not code or len(code) < 3 or len(code) > 20:
|
||||
return False
|
||||
return code.replace('_', '').replace('-', '').isalnum()
|
||||
|
||||
|
||||
def validate_amount(amount_str: str, min_amount: float = 0, max_amount: float = float('inf')) -> Optional[float]:
|
||||
try:
|
||||
amount = float(amount_str.replace(',', '.'))
|
||||
if min_amount <= amount <= max_amount:
|
||||
return amount
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_positive_integer(value: Union[str, int], max_value: int = None) -> Optional[int]:
|
||||
try:
|
||||
num = int(value)
|
||||
if num > 0 and (max_value is None or num <= max_value):
|
||||
return num
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_date_string(date_str: str, date_format: str = "%Y-%m-%d") -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.strptime(date_str, date_format)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def validate_url(url: str) -> bool:
|
||||
pattern = r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$'
|
||||
return re.match(pattern, url) is not None
|
||||
|
||||
|
||||
def validate_uuid(uuid_str: str) -> bool:
|
||||
pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
return re.match(pattern, uuid_str.lower()) is not None
|
||||
|
||||
|
||||
def validate_traffic_amount(traffic_str: str) -> Optional[int]:
|
||||
traffic_str = traffic_str.upper().strip()
|
||||
|
||||
if traffic_str in ['UNLIMITED', 'БЕЗЛИМИТ', '∞']:
|
||||
return 0
|
||||
|
||||
units = {
|
||||
'MB': 1,
|
||||
'GB': 1024,
|
||||
'TB': 1024 * 1024,
|
||||
'МБ': 1,
|
||||
'ГБ': 1024,
|
||||
'ТБ': 1024 * 1024
|
||||
}
|
||||
|
||||
for unit, multiplier in units.items():
|
||||
if traffic_str.endswith(unit):
|
||||
try:
|
||||
value = float(traffic_str[:-len(unit)].strip())
|
||||
return int(value * multiplier)
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
try:
|
||||
return int(float(traffic_str))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
|
||||
try:
|
||||
days_int = int(days)
|
||||
if 1 <= days_int <= 3650:
|
||||
return days_int
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def sanitize_html(text: str) -> str:
|
||||
allowed_tags = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'code', 'pre']
|
||||
|
||||
for tag in allowed_tags:
|
||||
text = re.sub(f'<{tag}>', f'<{tag}>', text, flags=re.IGNORECASE)
|
||||
text = re.sub(f'</{tag}>', f'</{tag}>', text, flags=re.IGNORECASE)
|
||||
|
||||
text = re.sub(r'<(?!/?(?:' + '|'.join(allowed_tags) + r')\b)[^>]*>', '', text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def validate_device_count(count: Union[str, int]) -> Optional[int]:
|
||||
try:
|
||||
count_int = int(count)
|
||||
if 1 <= count_int <= 10:
|
||||
return count_int
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_referral_code(code: str) -> bool:
|
||||
if not code:
|
||||
return False
|
||||
|
||||
if code.startswith('ref') and len(code) > 3:
|
||||
user_id_part = code[3:]
|
||||
return user_id_part.isdigit()
|
||||
|
||||
return validate_promocode(code)
|
||||
@@ -1,284 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from database import Database, UserSubscription, User, Subscription
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from referral_utils import process_referral_rewards
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AutoPayService:
|
||||
|
||||
def __init__(self, db: Database, api: Optional[RemnaWaveAPI] = None, bot=None):
|
||||
self.db = db
|
||||
self.api = api
|
||||
self.bot = bot
|
||||
self.is_running = False
|
||||
self.check_task = None
|
||||
|
||||
async def start(self):
|
||||
if self.is_running:
|
||||
logger.warning("AutoPay service is already running")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
logger.info("🔄 Starting AutoPay service...")
|
||||
|
||||
self.check_task = asyncio.create_task(self._periodic_check())
|
||||
|
||||
async def stop(self):
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
self.is_running = False
|
||||
logger.info("⏹ Stopping AutoPay service...")
|
||||
|
||||
if self.check_task:
|
||||
self.check_task.cancel()
|
||||
try:
|
||||
await self.check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _periodic_check(self):
|
||||
while self.is_running:
|
||||
try:
|
||||
await self.process_autopayments()
|
||||
# Проверяем каждые 30 минут
|
||||
await asyncio.sleep(1800)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("AutoPay periodic check cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in AutoPay periodic check: {e}")
|
||||
await asyncio.sleep(300)
|
||||
|
||||
async def process_autopayments(self) -> dict:
|
||||
logger.info("🔄 Processing autopayments...")
|
||||
|
||||
try:
|
||||
subscriptions_to_pay = await self.db.get_subscriptions_for_autopay()
|
||||
|
||||
if not subscriptions_to_pay:
|
||||
logger.info("No subscriptions ready for autopay")
|
||||
return {
|
||||
'processed': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'insufficient_balance': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
logger.info(f"Found {len(subscriptions_to_pay)} subscriptions for autopay")
|
||||
|
||||
stats = {
|
||||
'processed': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'insufficient_balance': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
for user_sub in subscriptions_to_pay:
|
||||
try:
|
||||
result = await self._process_single_autopayment(user_sub)
|
||||
stats['processed'] += 1
|
||||
|
||||
if result['success']:
|
||||
stats['successful'] += 1
|
||||
logger.info(f"✅ Autopay successful for user {user_sub.user_id}, subscription {user_sub.id}")
|
||||
elif result['reason'] == 'insufficient_balance':
|
||||
stats['insufficient_balance'] += 1
|
||||
logger.info(f"💳 Insufficient balance for user {user_sub.user_id}, subscription {user_sub.id}")
|
||||
else:
|
||||
stats['failed'] += 1
|
||||
stats['errors'].append(f"User {user_sub.user_id}: {result['reason']}")
|
||||
logger.warning(f"❌ Autopay failed for user {user_sub.user_id}: {result['reason']}")
|
||||
|
||||
except Exception as e:
|
||||
stats['processed'] += 1
|
||||
stats['failed'] += 1
|
||||
error_msg = f"User {user_sub.user_id}: {str(e)}"
|
||||
stats['errors'].append(error_msg)
|
||||
logger.error(f"Error processing autopay for user {user_sub.user_id}: {e}")
|
||||
|
||||
logger.info(f"📊 Autopay processing complete: {stats['successful']} successful, "
|
||||
f"{stats['failed']} failed, {stats['insufficient_balance']} insufficient balance")
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in process_autopayments: {e}")
|
||||
return {
|
||||
'processed': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'insufficient_balance': 0,
|
||||
'errors': [str(e)]
|
||||
}
|
||||
|
||||
async def _process_single_autopayment(self, user_sub: UserSubscription) -> dict:
|
||||
try:
|
||||
user = await self.db.get_user_by_telegram_id(user_sub.user_id)
|
||||
if not user:
|
||||
return {'success': False, 'reason': 'User not found'}
|
||||
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
return {'success': False, 'reason': 'Subscription plan not found'}
|
||||
|
||||
if subscription.is_trial:
|
||||
logger.info(f"Skipping autopay for trial subscription: user {user_sub.user_id}")
|
||||
return {'success': False, 'reason': 'Trial subscriptions are not eligible for autopay'}
|
||||
|
||||
if not user_sub.is_active or not user_sub.auto_pay_enabled:
|
||||
return {'success': False, 'reason': 'Subscription inactive or autopay disabled'}
|
||||
|
||||
if user.balance < subscription.price:
|
||||
await self._notify_insufficient_balance(user, subscription, user_sub)
|
||||
return {'success': False, 'reason': 'insufficient_balance'}
|
||||
|
||||
return await self._execute_autopayment(user, subscription, user_sub)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _process_single_autopayment: {e}")
|
||||
return {'success': False, 'reason': str(e)}
|
||||
|
||||
async def _execute_autopayment(self, user: User, subscription: Subscription, user_sub: UserSubscription) -> dict:
|
||||
try:
|
||||
user.balance -= subscription.price
|
||||
await self.db.update_user(user)
|
||||
|
||||
now = datetime.utcnow()
|
||||
if user_sub.expires_at > now:
|
||||
new_expiry = user_sub.expires_at + timedelta(days=subscription.duration_days)
|
||||
else:
|
||||
new_expiry = now + timedelta(days=subscription.duration_days)
|
||||
|
||||
user_sub.expires_at = new_expiry
|
||||
user_sub.is_active = True
|
||||
await self.db.update_user_subscription(user_sub)
|
||||
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
remna_user_details = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
|
||||
if remna_user_details:
|
||||
user_uuid = remna_user_details.get('uuid')
|
||||
if user_uuid:
|
||||
expiry_str = new_expiry.isoformat() + 'Z'
|
||||
update_data = {
|
||||
'enable': True,
|
||||
'expireAt': expiry_str
|
||||
}
|
||||
await self.api.update_user(user_uuid, update_data)
|
||||
logger.info(f"Updated RemnaWave expiry for user {user_sub.user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update RemnaWave expiry: {e}")
|
||||
|
||||
payment = await self.db.create_payment(
|
||||
user_id=user_sub.user_id,
|
||||
amount=-subscription.price,
|
||||
payment_type='autopay',
|
||||
description=f'Автоплатеж: {subscription.name}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await process_referral_rewards(
|
||||
user_sub.user_id,
|
||||
subscription.price,
|
||||
payment.id,
|
||||
self.db,
|
||||
self.bot,
|
||||
payment_type='autopay'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process referral rewards for autopay: {e}")
|
||||
|
||||
await self._notify_successful_autopay(user, subscription, user_sub, new_expiry)
|
||||
|
||||
return {'success': True, 'reason': 'Payment processed successfully'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing autopayment: {e}")
|
||||
try:
|
||||
user.balance += subscription.price
|
||||
await self.db.update_user(user)
|
||||
except:
|
||||
pass
|
||||
return {'success': False, 'reason': str(e)}
|
||||
|
||||
async def _notify_successful_autopay(self, user: User, subscription: Subscription,
|
||||
user_sub: UserSubscription, new_expiry: datetime):
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils import format_datetime
|
||||
|
||||
text = f"✅ Автоматическое продление подписки\n\n"
|
||||
text += f"📋 Подписка: {subscription.name}\n"
|
||||
text += f"💰 Списано: {subscription.price} руб.\n"
|
||||
text += f"📅 Продлено до: {format_datetime(new_expiry, user.language)}\n"
|
||||
text += f"💳 Остаток на балансе: {user.balance} руб.\n\n"
|
||||
text += f"🔄 Следующее продление произойдет автоматически за {user_sub.auto_pay_days_before} дн. до истечения.\n\n"
|
||||
text += f"ℹ️ Для отключения автоплатежа перейдите в 'Мои подписки' → выберите подписку"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")],
|
||||
[InlineKeyboardButton(text="💰 Баланс", callback_data="balance")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
text,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send autopay success notification: {e}")
|
||||
|
||||
async def _notify_insufficient_balance(self, user: User, subscription: Subscription, user_sub: UserSubscription):
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils import format_datetime
|
||||
|
||||
needed = subscription.price - user.balance
|
||||
days_left = (user_sub.expires_at - datetime.utcnow()).days
|
||||
|
||||
text = f"⚠️ Не удалось автоматически продлить подписку\n\n"
|
||||
text += f"📋 Подписка: {subscription.name}\n"
|
||||
text += f"💰 Нужно для продления: {subscription.price} руб.\n"
|
||||
text += f"💳 Ваш баланс: {user.balance} руб.\n"
|
||||
text += f"💸 Недостает: {needed} руб.\n\n"
|
||||
text += f"📅 Подписка истекает: {format_datetime(user_sub.expires_at, user.language)}\n"
|
||||
text += f"⏰ Осталось дней: {days_left}\n\n"
|
||||
text += f"💡 Пополните баланс для автоматического продления"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="topup_balance")],
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
text,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send insufficient balance notification: {e}")
|
||||
|
||||
async def get_service_status(self) -> dict:
|
||||
return {
|
||||
'is_running': self.is_running,
|
||||
'check_interval': 1800, # 30 минут
|
||||
'has_api': self.api is not None,
|
||||
'has_bot': self.bot is not None
|
||||
}
|
||||
-1434
File diff suppressed because it is too large
Load Diff
@@ -1,139 +0,0 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
BOT_TOKEN: str
|
||||
BOT_USERNAME: str
|
||||
DATABASE_URL: str
|
||||
REMNAWAVE_URL: str
|
||||
REMNAWAVE_TOKEN: str
|
||||
SUBSCRIPTION_BASE_URL: str
|
||||
ADMIN_IDS: List[int]
|
||||
SUPPORT_USERNAME: str
|
||||
DEFAULT_LANGUAGE: str
|
||||
TRIAL_ENABLED: bool
|
||||
TRIAL_DURATION_DAYS: int
|
||||
TRIAL_TRAFFIC_GB: int
|
||||
TRIAL_SQUAD_UUID: str
|
||||
REFERRAL_FIRST_REWARD: float
|
||||
REFERRAL_REFERRED_BONUS: float
|
||||
REFERRAL_THRESHOLD: float
|
||||
REFERRAL_PERCENTAGE: float
|
||||
MONITOR_ENABLED: bool = True
|
||||
MONITOR_CHECK_INTERVAL: int = 3600
|
||||
MONITOR_DAILY_CHECK_HOUR: int = 10
|
||||
MONITOR_WARNING_DAYS: int = 3
|
||||
DELETE_EXPIRED_TRIAL_DAYS: int = 1
|
||||
DELETE_EXPIRED_REGULAR_DAYS: int = 7
|
||||
AUTO_DELETE_ENABLED: bool = False
|
||||
|
||||
LUCKY_GAME_ENABLED: bool = True
|
||||
LUCKY_GAME_REWARD: float = 50.0
|
||||
LUCKY_GAME_NUMBERS: int = 30
|
||||
LUCKY_GAME_WINNING_COUNT: int = 3
|
||||
|
||||
STARS_ENABLED: bool = True
|
||||
STARS_RATES: Dict[int, float] = None
|
||||
|
||||
def load_config() -> Config:
|
||||
|
||||
def parse_admin_ids(admin_ids_str: str) -> List[int]:
|
||||
if not admin_ids_str:
|
||||
return []
|
||||
try:
|
||||
return [int(id_str.strip()) for id_str in admin_ids_str.split(',') if id_str.strip()]
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
def get_bool(key: str, default: bool = False) -> bool:
|
||||
value = os.getenv(key, '').lower()
|
||||
return value in ('true', '1', 'yes', 'on')
|
||||
|
||||
def get_int(key: str, default: int = 0) -> int:
|
||||
try:
|
||||
return int(os.getenv(key, str(default)))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def get_float(key: str, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(os.getenv(key, str(default)))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def parse_stars_rates() -> Dict[int, float]:
|
||||
default_rates = {
|
||||
100: 150.0,
|
||||
150: 220.0,
|
||||
250: 400.0,
|
||||
350: 500.0,
|
||||
500: 800.0,
|
||||
750: 1150.0,
|
||||
1000: 1500.0
|
||||
}
|
||||
|
||||
custom_rates = {}
|
||||
|
||||
for stars in [100, 150, 250, 350, 500, 750, 1000]:
|
||||
env_key = f"STARS_{stars}_RATE"
|
||||
rate = get_float(env_key, 0.0)
|
||||
if rate > 0:
|
||||
custom_rates[stars] = rate
|
||||
|
||||
if custom_rates:
|
||||
return custom_rates
|
||||
|
||||
return default_rates
|
||||
|
||||
return Config(
|
||||
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
|
||||
BOT_USERNAME=os.getenv('BOT_USERNAME', ''),
|
||||
DATABASE_URL=os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///bot.db'),
|
||||
REMNAWAVE_URL=os.getenv('REMNAWAVE_URL', ''),
|
||||
REMNAWAVE_TOKEN=os.getenv('REMNAWAVE_TOKEN', ''),
|
||||
SUBSCRIPTION_BASE_URL=os.getenv('SUBSCRIPTION_BASE_URL', ''),
|
||||
ADMIN_IDS=parse_admin_ids(os.getenv('ADMIN_IDS', '')),
|
||||
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
|
||||
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
|
||||
TRIAL_ENABLED=get_bool('TRIAL_ENABLED', False),
|
||||
TRIAL_DURATION_DAYS=get_int('TRIAL_DURATION_DAYS', 3),
|
||||
TRIAL_TRAFFIC_GB=get_int('TRIAL_TRAFFIC_GB', 2),
|
||||
TRIAL_SQUAD_UUID=os.getenv('TRIAL_SQUAD_UUID', ''),
|
||||
REFERRAL_FIRST_REWARD=get_float('REFERRAL_FIRST_REWARD', 150.0),
|
||||
REFERRAL_REFERRED_BONUS=get_float('REFERRAL_REFERRED_BONUS', 150.0),
|
||||
REFERRAL_THRESHOLD=get_float('REFERRAL_THRESHOLD', 300.0),
|
||||
REFERRAL_PERCENTAGE=get_float('REFERRAL_PERCENTAGE', 0.25),
|
||||
MONITOR_ENABLED=get_bool('MONITOR_ENABLED', True),
|
||||
MONITOR_CHECK_INTERVAL=get_int('MONITOR_CHECK_INTERVAL', 3600),
|
||||
MONITOR_DAILY_CHECK_HOUR=get_int('MONITOR_DAILY_CHECK_HOUR', 10),
|
||||
MONITOR_WARNING_DAYS=get_int('MONITOR_WARNING_DAYS', 3),
|
||||
DELETE_EXPIRED_TRIAL_DAYS=get_int('DELETE_EXPIRED_TRIAL_DAYS', 1),
|
||||
DELETE_EXPIRED_REGULAR_DAYS=get_int('DELETE_EXPIRED_REGULAR_DAYS', 7),
|
||||
AUTO_DELETE_ENABLED=get_bool('AUTO_DELETE_ENABLED', False),
|
||||
LUCKY_GAME_ENABLED=get_bool('LUCKY_GAME_ENABLED', True),
|
||||
LUCKY_GAME_REWARD=get_float('LUCKY_GAME_REWARD', 50.0),
|
||||
LUCKY_GAME_NUMBERS=get_int('LUCKY_GAME_NUMBERS', 30),
|
||||
LUCKY_GAME_WINNING_COUNT=get_int('LUCKY_GAME_WINNING_COUNT', 3),
|
||||
STARS_ENABLED=get_bool('STARS_ENABLED', True),
|
||||
STARS_RATES=parse_stars_rates()
|
||||
)
|
||||
|
||||
def debug_environment():
|
||||
env_vars = [
|
||||
'BOT_TOKEN', 'BOT_USERNAME', 'DATABASE_URL',
|
||||
'REMNAWAVE_URL', 'REMNAWAVE_TOKEN', 'ADMIN_IDS',
|
||||
'REFERRAL_FIRST_REWARD', 'REFERRAL_THRESHOLD',
|
||||
'MONITOR_ENABLED', 'MONITOR_CHECK_INTERVAL',
|
||||
'DELETE_EXPIRED_TRIAL_DAYS', 'DELETE_EXPIRED_REGULAR_DAYS', 'AUTO_DELETE_ENABLED',
|
||||
'LUCKY_GAME_ENABLED', 'LUCKY_GAME_REWARD',
|
||||
'STARS_ENABLED', 'STARS_100_RATE', 'STARS_500_RATE', 'STARS_1000_RATE'
|
||||
]
|
||||
|
||||
print("📋 Environment variables:")
|
||||
for var in env_vars:
|
||||
value = os.getenv(var, 'NOT SET')
|
||||
if 'TOKEN' in var and value != 'NOT SET':
|
||||
value = value[:10] + "..." if len(value) > 10 else value
|
||||
print(f" {var}: {value}")
|
||||
-2437
File diff suppressed because it is too large
Load Diff
+39
-64
@@ -1,98 +1,73 @@
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: remnawave_bot_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: remnawave_bot
|
||||
POSTGRES_USER: remnawave_user
|
||||
POSTGRES_PASSWORD: secure_password_123
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
|
||||
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
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U remnawave_user -d remnawave_bot"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# RemnaWave Bot
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
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:
|
||||
# Override database URL to use PostgreSQL container
|
||||
DATABASE_URL: postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
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
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'print(\"Bot is running\")'"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Redis (optional, for caching and session storage)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass redis_password_123
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./logs:/app/logs:rw
|
||||
- ./data:/app/data:rw
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# Nginx (optional, for serving static files or reverse proxy)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: remnawave_bot_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
- ./static:/usr/share/nginx/html:ro
|
||||
networks:
|
||||
- bot_network
|
||||
depends_on:
|
||||
- bot
|
||||
profiles:
|
||||
- with-nginx
|
||||
user: "1000:1000"
|
||||
command: >
|
||||
bash -c "
|
||||
mkdir -p /app/logs /app/data &&
|
||||
python main.py
|
||||
"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
Экстренное исправление проблемы с отображением подписок
|
||||
Этот патч добавляет недостающие поля в таблицу user_subscriptions
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем корневую директорию в путь
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from config import load_config
|
||||
from database import Database
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def check_and_add_column(db, column_name, column_definition):
|
||||
"""Проверяет и добавляет колонку в таблицу"""
|
||||
try:
|
||||
# Отдельная транзакция для проверки
|
||||
async with db.engine.begin() as conn:
|
||||
await conn.execute(text(f"SELECT {column_name} FROM user_subscriptions LIMIT 1"))
|
||||
logger.info(f"✅ Поле {column_name} уже существует")
|
||||
return True
|
||||
except Exception:
|
||||
# Отдельная транзакция для добавления колонки
|
||||
try:
|
||||
async with db.engine.begin() as conn:
|
||||
logger.info(f"➕ Добавляю поле {column_name}...")
|
||||
await conn.execute(text(f"""
|
||||
ALTER TABLE user_subscriptions
|
||||
ADD COLUMN {column_name} {column_definition}
|
||||
"""))
|
||||
logger.info(f"✅ Поле {column_name} добавлено")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при добавлении {column_name}: {e}")
|
||||
return False
|
||||
|
||||
async def emergency_fix():
|
||||
"""Экстренное исправление базы данных"""
|
||||
|
||||
try:
|
||||
# Загружаем конфигурацию
|
||||
config = load_config()
|
||||
|
||||
# Подключаемся к базе данных
|
||||
db = Database(config.DATABASE_URL)
|
||||
|
||||
logger.info("🔧 Выполняю экстренное исправление базы данных...")
|
||||
|
||||
# Проверяем существование таблицы user_subscriptions
|
||||
try:
|
||||
async with db.engine.begin() as conn:
|
||||
result = await conn.execute(text("SELECT COUNT(*) FROM user_subscriptions"))
|
||||
count = result.scalar()
|
||||
logger.info(f"📊 Найдено {count} подписок в таблице user_subscriptions")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Таблица user_subscriptions не найдена: {e}")
|
||||
await db.close()
|
||||
return
|
||||
|
||||
# Добавляем поля по одному в отдельных транзакциях
|
||||
success1 = await check_and_add_column(db, "auto_pay_enabled", "BOOLEAN DEFAULT FALSE")
|
||||
success2 = await check_and_add_column(db, "auto_pay_days_before", "INTEGER DEFAULT 3")
|
||||
|
||||
# Финальная проверка в отдельной транзакции
|
||||
if success1 and success2:
|
||||
try:
|
||||
async with db.engine.begin() as conn:
|
||||
result = await conn.execute(text("""
|
||||
SELECT id, auto_pay_enabled, auto_pay_days_before
|
||||
FROM user_subscriptions LIMIT 1
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
logger.info("✅ Все поля доступны для чтения")
|
||||
logger.info(f"🔍 Пример записи: id={row[0]}, auto_pay_enabled={row[1]}, auto_pay_days_before={row[2]}")
|
||||
else:
|
||||
logger.info("✅ Все поля доступны, но таблица пуста")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Поля все еще недоступны: {e}")
|
||||
else:
|
||||
logger.error("❌ Не удалось добавить все необходимые поля")
|
||||
|
||||
await db.close()
|
||||
logger.info("🎉 Экстренное исправление завершено!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 Критическая ошибка: {e}")
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(emergency_fix())
|
||||
-2020
File diff suppressed because it is too large
Load Diff
-930
@@ -1,930 +0,0 @@
|
||||
from database import Subscription
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from typing import List, Optional, Dict
|
||||
from translations import t
|
||||
from datetime import datetime
|
||||
|
||||
def language_keyboard() -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🇷🇺 Русский", callback_data="lang_ru"),
|
||||
InlineKeyboardButton(text="🇺🇸 English", callback_data="lang_en")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def main_menu_keyboard(lang: str = 'ru', is_admin: bool = False, show_trial: bool = False, show_lucky_game: bool = True) -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(text="💰 " + t('balance', lang), callback_data="balance"),
|
||||
InlineKeyboardButton(text="📋 " + t('my_subscriptions', lang), callback_data="my_subscriptions")
|
||||
],
|
||||
[InlineKeyboardButton(text="🛒 " + t('buy_subscription', lang), callback_data="buy_subscription")],
|
||||
]
|
||||
|
||||
if show_trial:
|
||||
buttons.insert(1, [InlineKeyboardButton(text="🆓 Тестовая подписка", callback_data="trial_subscription")])
|
||||
|
||||
if show_lucky_game:
|
||||
buttons.append([InlineKeyboardButton(text="🎰 Проверь свою удачу!", callback_data="lucky_game")])
|
||||
|
||||
buttons.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 " + t('promocode', lang), callback_data="promocode"),
|
||||
InlineKeyboardButton(text="👥 Рефералы", callback_data="referral_program")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💬 " + t('support', lang), callback_data="support")
|
||||
],
|
||||
[InlineKeyboardButton(text="🌐 " + t('change_language', lang), callback_data="change_language")],
|
||||
[InlineKeyboardButton(text="📜 Правила сервиса", callback_data="service_rules")]
|
||||
])
|
||||
|
||||
if is_admin:
|
||||
buttons.append([InlineKeyboardButton(text="⚙️ " + t('admin_panel', lang), callback_data="admin_panel")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 " + t('topup_balance', lang), callback_data="topup_balance")],
|
||||
[InlineKeyboardButton(text="📊 " + t('payment_history', lang), callback_data="payment_history")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
# [InlineKeyboardButton(text="💳 " + t('topup_card', lang), callback_data="topup_card")],
|
||||
[InlineKeyboardButton(text="👨💼 " + t('topup_support', lang), callback_data="topup_support")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def subscriptions_keyboard(subscriptions: List[dict], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
for i in range(0, len(subscriptions), 2):
|
||||
row = []
|
||||
for j in range(2):
|
||||
if i + j < len(subscriptions):
|
||||
sub = subscriptions[i + j]
|
||||
price_text = f"{sub['price']:.0f}₽"
|
||||
if sub['price'] <= 100:
|
||||
emoji = "🥉"
|
||||
elif sub['price'] <= 300:
|
||||
emoji = "🥈"
|
||||
else:
|
||||
emoji = "🥇"
|
||||
|
||||
row.append(InlineKeyboardButton(
|
||||
text=f"{emoji} {sub['name']} - {price_text}",
|
||||
callback_data=f"buy_sub_{sub['id']}"
|
||||
))
|
||||
buttons.append(row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def subscription_detail_keyboard(subscription_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="💎 " + t('buy_subscription_btn', lang, price=0),
|
||||
callback_data=f"confirm_buy_{subscription_id}"
|
||||
)],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="buy_subscription")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def user_subscriptions_keyboard(user_subscriptions: List[dict], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
for sub in user_subscriptions:
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"📱 {sub['name']}",
|
||||
callback_data=f"view_sub_{sub['id']}"
|
||||
)])
|
||||
|
||||
if not user_subscriptions:
|
||||
buttons.append([InlineKeyboardButton(text="🛒 " + t('buy_subscription', lang), callback_data="buy_subscription")])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def user_subscription_detail_keyboard(subscription_id: int, lang: str = 'ru',
|
||||
show_extend: bool = False, is_imported: bool = False,
|
||||
is_trial: bool = False, autopay_enabled: bool = False) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if is_imported:
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
buttons.append([InlineKeyboardButton(text="🛒 Купить новую подписку", callback_data="buy_subscription")])
|
||||
elif is_trial:
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
buttons.append([InlineKeyboardButton(text="🛒 Купить полную подписку", callback_data="buy_subscription")])
|
||||
else:
|
||||
if show_extend:
|
||||
buttons.append([InlineKeyboardButton(text="⏰ " + t('extend_subscription', lang), callback_data=f"extend_sub_{subscription_id}")])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
|
||||
if autopay_enabled:
|
||||
autopay_text = "🔄✅ Настроить автоплатеж"
|
||||
else:
|
||||
autopay_text = "🔄❌ Настроить автоплатеж"
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=autopay_text, callback_data=f"autopay_settings_{subscription_id}")])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="my_subscriptions")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def autopay_confirmation_keyboard(subscription_id: int, action: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да", callback_data=f"confirm_autopay_{action}_{subscription_id}"),
|
||||
InlineKeyboardButton(text="❌ Нет", callback_data=f"autopay_settings_{subscription_id}")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def autopay_help_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Клавиатура помощи по автоплатежам"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="topup_balance")],
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def extend_subscription_keyboard(subscription_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, продлить", callback_data=f"confirm_extend_{subscription_id}"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_sub_{subscription_id}")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def back_keyboard(callback_data: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data=callback_data)]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def cancel_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ " + t('cancel', lang), callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
|
||||
def admin_menu_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📦 " + t('manage_subscriptions', lang), callback_data="admin_subscriptions"),
|
||||
InlineKeyboardButton(text="👥 " + t('manage_users', lang), callback_data="admin_users")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💰 " + t('manage_balance', lang), callback_data="admin_balance"),
|
||||
InlineKeyboardButton(text="🎁 " + t('manage_promocodes', lang), callback_data="admin_promocodes")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📨 " + t('send_message', lang), callback_data="admin_messages"),
|
||||
InlineKeyboardButton(text="👥 Рефералы", callback_data="admin_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📜 Правила сервиса", callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text="🔄 Автоплатежи", callback_data="admin_autopay")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system"),
|
||||
InlineKeyboardButton(text="🔍 Мониторинг подписок", callback_data="admin_monitor")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 " + t('statistics', lang), callback_data="admin_stats")
|
||||
],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_subscriptions_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="➕ " + t('create_subscription', lang), callback_data="create_subscription")],
|
||||
[InlineKeyboardButton(text="📋 Список подписок", callback_data="list_admin_subscriptions")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 Список пользователей", callback_data="list_users")],
|
||||
[InlineKeyboardButton(text="📋 Все подписки пользователей", callback_data="admin_user_subscriptions_all")],
|
||||
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 Список пользователей", callback_data="list_users")],
|
||||
[InlineKeyboardButton(text="📋 Все подписки пользователей", callback_data="admin_user_subscriptions_all")],
|
||||
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_user_subscriptions_filters_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Клавиатура фильтров для подписок пользователей"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🟢 Активные", callback_data="filter_subs_active"),
|
||||
InlineKeyboardButton(text="🔴 Истекшие", callback_data="filter_subs_expired")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄✅ С автоплатежом", callback_data="filter_subs_autopay"),
|
||||
InlineKeyboardButton(text="⏰ Истекают скоро", callback_data="filter_subs_expiring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🆓 Триальные", callback_data="filter_subs_trial"),
|
||||
InlineKeyboardButton(text="📦 Импортированные", callback_data="filter_subs_imported")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Все подписки", callback_data="admin_user_subscriptions_all"),
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_user_subscription_detail_keyboard(subscription_id: int, user_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Клавиатура для детального просмотра подписки пользователя"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"edit_user_sub_{subscription_id}"),
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data=f"refresh_user_sub_{subscription_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_detail_{user_id}"),
|
||||
InlineKeyboardButton(text="📋 К списку подписок", callback_data="admin_user_subscriptions_all")
|
||||
],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def user_subscriptions_pagination_keyboard(current_page: int, total_pages: int,
|
||||
filter_type: str = "all", lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 0:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"user_subs_page_{current_page - 1}_{filter_type}"))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(text=f"{current_page + 1}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if current_page < total_pages - 1:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"user_subs_page_{current_page + 1}_{filter_type}"))
|
||||
|
||||
buttons.append(nav_row)
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_user_subscriptions_filters"),
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data=f"refresh_user_subs_{filter_type}")
|
||||
])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💸 Пополнить баланс пользователю", callback_data="admin_add_balance")],
|
||||
[InlineKeyboardButton(text="📊 История платежей", callback_data="admin_payment_history")],
|
||||
[InlineKeyboardButton(text="⭐ Telegram Stars", callback_data="admin_stars_payments")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_promocodes_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="➕ " + t('create_promocode', lang), callback_data="create_promocode")],
|
||||
[InlineKeyboardButton(text="📋 Управление промокодами", callback_data="list_promocodes")],
|
||||
[InlineKeyboardButton(text="📊 Статистика промокодов", callback_data="promocodes_stats")],
|
||||
[InlineKeyboardButton(text="🧹 Очистить истекшие", callback_data="cleanup_expired_promos")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def promocodes_management_keyboard(promocodes: List, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
for promo in promocodes[:15]:
|
||||
status_emoji = "🟢" if promo.is_active else "🔴"
|
||||
|
||||
if promo.expires_at and promo.expires_at < datetime.utcnow():
|
||||
status_emoji = "⏰"
|
||||
|
||||
usage_text = f"{promo.used_count}/{promo.usage_limit}"
|
||||
button_text = f"{status_emoji} {promo.code} ({promo.discount_amount}₽) [{usage_text}]"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"promo_info_{promo.id}"
|
||||
)
|
||||
])
|
||||
|
||||
control_buttons = []
|
||||
|
||||
if not promo.code.startswith('REF'):
|
||||
toggle_text = "🔴" if promo.is_active else "🟢"
|
||||
control_buttons.append(
|
||||
InlineKeyboardButton(text=toggle_text, callback_data=f"toggle_promo_{promo.id}")
|
||||
)
|
||||
|
||||
control_buttons.append(
|
||||
InlineKeyboardButton(text="✏️", callback_data=f"edit_promo_{promo.id}")
|
||||
)
|
||||
|
||||
control_buttons.append(
|
||||
InlineKeyboardButton(text="🗑", callback_data=f"delete_promo_{promo.id}")
|
||||
)
|
||||
else:
|
||||
control_buttons.append(
|
||||
InlineKeyboardButton(text="👥 Реферальный", callback_data="noop")
|
||||
)
|
||||
|
||||
if control_buttons:
|
||||
buttons.append(control_buttons)
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="➕ Создать промокод", callback_data="create_promocode")
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🧹 Очистить истекшие", callback_data="cleanup_expired_promos"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="promocodes_stats")
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_promocodes")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def promocode_edit_keyboard(promo_id: int, language: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="💰 Изменить скидку", callback_data=f"edit_promo_field_{promo_id}_discount")],
|
||||
[InlineKeyboardButton(text="📊 Изменить лимит", callback_data=f"edit_promo_field_{promo_id}_limit")],
|
||||
[InlineKeyboardButton(text="⏰ Изменить срок", callback_data=f"edit_promo_field_{promo_id}_expiry")],
|
||||
[InlineKeyboardButton(text="🗑 Удалить промокод", callback_data=f"delete_promo_{promo_id}")],
|
||||
[InlineKeyboardButton(text=t('back', language), callback_data="list_promocodes")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def promocode_info_keyboard(promo_id: int, is_referral: bool = False, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if not is_referral:
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"edit_promo_{promo_id}")],
|
||||
[
|
||||
InlineKeyboardButton(text="🟢/🔴 Переключить", callback_data=f"toggle_promo_{promo_id}"),
|
||||
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"delete_promo_{promo_id}")
|
||||
]
|
||||
])
|
||||
else:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="👥 Реферальный код", callback_data="noop")
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔙 К списку", callback_data="list_promocodes")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def bulk_promocodes_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="🧹 Удалить все истекшие", callback_data="confirm_cleanup_expired")],
|
||||
[InlineKeyboardButton(text="🔴 Деактивировать все", callback_data="confirm_deactivate_all")],
|
||||
[InlineKeyboardButton(text="📊 Экспорт статистики", callback_data="export_promo_stats")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="list_promocodes")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def confirmation_keyboard(confirm_callback: str, cancel_callback: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да", callback_data=confirm_callback),
|
||||
InlineKeyboardButton(text="❌ Нет", callback_data=cancel_callback)
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def pagination_keyboard(page: int, total_pages: int, prefix: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
if page > 1:
|
||||
nav_buttons.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"{prefix}_page_{page-1}"))
|
||||
|
||||
nav_buttons.append(InlineKeyboardButton(text=f"📄 {page}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if page < total_pages:
|
||||
nav_buttons.append(InlineKeyboardButton(text="Вперед ➡️", callback_data=f"{prefix}_page_{page+1}"))
|
||||
|
||||
buttons.append(nav_buttons)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_subscriptions_list_keyboard(subs: List[Subscription], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
for sub in subs:
|
||||
status_emoji = "🟢" if sub.is_active else "🔴"
|
||||
price = f"{sub.price:.0f}₽"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {sub.name} — {price}",
|
||||
callback_data=f"list_sub_{sub.id}"
|
||||
)
|
||||
])
|
||||
|
||||
control_buttons = [
|
||||
InlineKeyboardButton(text="✏️", callback_data=f"edit_sub_{sub.id}"),
|
||||
InlineKeyboardButton(
|
||||
text="🟢" if not sub.is_active else "🔴",
|
||||
callback_data=f"toggle_sub_{sub.id}"
|
||||
),
|
||||
InlineKeyboardButton(text="🗑", callback_data=f"delete_sub_{sub.id}")
|
||||
]
|
||||
|
||||
buttons.append(control_buttons)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="➕ Создать подписку", callback_data="create_subscription")])
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_subscriptions")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_payment_keyboard(payment_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Одобрить платеж", callback_data=f"approve_payment_{payment_id}"),
|
||||
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"reject_payment_{payment_id}")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_messages_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👤 " + t('send_to_user', lang), callback_data="admin_send_to_user")],
|
||||
[InlineKeyboardButton(text="📢 " + t('send_to_all', lang), callback_data="admin_send_to_all")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def quick_topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💳 100₽", callback_data="quick_topup_100"),
|
||||
InlineKeyboardButton(text="💳 300₽", callback_data="quick_topup_300")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💳 500₽", callback_data="quick_topup_500"),
|
||||
InlineKeyboardButton(text="💳 1000₽", callback_data="quick_topup_1000")
|
||||
],
|
||||
[InlineKeyboardButton(text="💰 Другая сумма", callback_data="topup_support")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def connection_keyboard(subscription_url: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription_url)],
|
||||
[InlineKeyboardButton(text="📱 Инструкция", callback_data="connection_guide")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="my_subscriptions")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def trial_subscription_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="✅ Получить тестовую подписку", callback_data="confirm_trial")],
|
||||
[InlineKeyboardButton(text=t('back', lang), callback_data="main_menu")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_monitor_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статус сервиса", callback_data="monitor_status")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_system_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Системная статистика", callback_data="system_stats")],
|
||||
[InlineKeyboardButton(text="🖥 Управление нодами", callback_data="nodes_management")],
|
||||
[InlineKeyboardButton(text="👥 Пользователи системы", callback_data="system_users")],
|
||||
[InlineKeyboardButton(text="🔄 Синхронизация с RemnaWave", callback_data="sync_remnawave")],
|
||||
[InlineKeyboardButton(text="🔍 Отладка API", callback_data="debug_api_comprehensive")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def system_stats_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить статистику", callback_data="refresh_system_stats")],
|
||||
[InlineKeyboardButton(text="🖥 Ноды", callback_data="nodes_management")],
|
||||
[InlineKeyboardButton(text="👥 Системные пользователи", callback_data="system_users")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def nodes_management_keyboard(nodes: List[Dict], lang: str = 'ru', timestamp: int = None) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if nodes:
|
||||
online_count = len([n for n in nodes if n.get('status') == 'online'])
|
||||
total_count = len(nodes)
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📊 Ноды: {online_count}/{total_count} онлайн",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
for i, node in enumerate(nodes[:5]):
|
||||
status = node.get('status', 'unknown')
|
||||
|
||||
if status == 'online':
|
||||
status_emoji = "🟢"
|
||||
elif status == 'disabled':
|
||||
status_emoji = "⚫"
|
||||
elif status == 'disconnected':
|
||||
status_emoji = "🔴"
|
||||
elif status == 'xray_stopped':
|
||||
status_emoji = "🟡"
|
||||
else:
|
||||
status_emoji = "⚪"
|
||||
|
||||
node_name = node.get('name', f'Node-{i+1}')
|
||||
node_id = node.get('id', node.get('uuid'))
|
||||
|
||||
if len(node_name) > 20:
|
||||
display_name = node_name[:17] + "..."
|
||||
else:
|
||||
display_name = node_name
|
||||
|
||||
usage_info = ""
|
||||
if node.get('cpuUsage'):
|
||||
usage_info += f" CPU:{node['cpuUsage']:.0f}%"
|
||||
if node.get('memUsage'):
|
||||
usage_info += f" MEM:{node['memUsage']:.0f}%"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {display_name}{usage_info}",
|
||||
callback_data=f"node_details_{node_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🔄",
|
||||
callback_data=f"restart_node_{node_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⚙️",
|
||||
callback_data=f"node_settings_{node_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(nodes) > 5:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"... и еще {len(nodes) - 5} нод",
|
||||
callback_data="show_all_nodes"
|
||||
)
|
||||
])
|
||||
else:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Ноды не найдены",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить все", callback_data="restart_all_nodes"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="nodes_statistics")
|
||||
])
|
||||
|
||||
refresh_callback = f"refresh_nodes_stats_{timestamp}" if timestamp else "refresh_nodes_stats"
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data=refresh_callback)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def system_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статистика пользователей", callback_data="users_statistics")],
|
||||
[InlineKeyboardButton(text="👥 Список всех пользователей", callback_data="list_all_system_users")],
|
||||
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user_uuid")],
|
||||
[InlineKeyboardButton(text="🔍 Отладка API пользователей", callback_data="debug_users_api")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_system")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def bulk_operations_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="bulk_reset_traffic")],
|
||||
[InlineKeyboardButton(text="❌ Отключить пользователей", callback_data="bulk_disable_users")],
|
||||
[InlineKeyboardButton(text="✅ Включить пользователей", callback_data="bulk_enable_users")],
|
||||
[InlineKeyboardButton(text="🗑 Удалить пользователей", callback_data="bulk_delete_users")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="system_users")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def confirm_restart_keyboard(node_id: str = None, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
action = f"confirm_restart_node_{node_id}" if node_id else "confirm_restart_all_nodes"
|
||||
back_action = f"node_details_{node_id}" if node_id else "nodes_management"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, перезагрузить", callback_data=action),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=back_action)
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_referrals_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статистика рефералов", callback_data="referral_statistics")],
|
||||
[InlineKeyboardButton(text="👥 Список рефереров", callback_data="list_referrers")],
|
||||
[InlineKeyboardButton(text="💰 История выплат", callback_data="referral_payments")],
|
||||
[InlineKeyboardButton(text="⚙️ Настройки программы", callback_data="referral_settings")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def lucky_game_keyboard(can_play: bool, time_left_str: str = "", lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if can_play:
|
||||
buttons.append([InlineKeyboardButton(text="🎲 Играть!", callback_data="start_lucky_game")])
|
||||
else:
|
||||
buttons.append([InlineKeyboardButton(text=f"⏰ Приходи через {time_left_str}", callback_data="noop")])
|
||||
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="📈 История игр", callback_data="lucky_game_history")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def lucky_numbers_keyboard(numbers_count: int) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
for i in range(0, numbers_count, 5):
|
||||
row = []
|
||||
for j in range(5):
|
||||
if i + j + 1 <= numbers_count:
|
||||
number = i + j + 1
|
||||
row.append(InlineKeyboardButton(
|
||||
text=str(number),
|
||||
callback_data=f"choose_number_{number}"
|
||||
))
|
||||
buttons.append(row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="❌ Отмена", callback_data="lucky_game")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def lucky_game_result_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📈 История игр", callback_data="lucky_game_history")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
|
||||
def topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⭐ Telegram Stars", callback_data="topup_stars")],
|
||||
[InlineKeyboardButton(text="👨💼 " + t('topup_support', lang), callback_data="topup_support")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def stars_topup_keyboard(stars_rates: Dict[int, float], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
sorted_rates = sorted(stars_rates.items())
|
||||
|
||||
for i in range(0, len(sorted_rates), 2):
|
||||
row = []
|
||||
for j in range(2):
|
||||
if i + j < len(sorted_rates):
|
||||
stars, rubles = sorted_rates[i + j]
|
||||
if stars >= 500:
|
||||
emoji = "🔥" # Выгодное предложение
|
||||
elif stars >= 250:
|
||||
emoji = "💎" # Хорошее предложение
|
||||
else:
|
||||
emoji = "⭐" # Базовое предложение
|
||||
|
||||
button_text = f"{emoji} {stars} ⭐ → {rubles:.0f}₽"
|
||||
row.append(InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"buy_stars_{stars}"
|
||||
))
|
||||
buttons.append(row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="topup_balance")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def stars_payment_keyboard(stars_amount: int, rub_amount: float, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="topup_stars")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def service_rules_keyboard(current_page: int, total_pages: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 0:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"rules_page_{current_page - 1}"))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(text=f"{current_page + 1}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if current_page < total_pages - 1:
|
||||
nav_row.append(InlineKeyboardButton(text="Вперед ➡️", callback_data=f"rules_page_{current_page + 1}"))
|
||||
|
||||
buttons.append(nav_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_rules_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 Список правил", callback_data="admin_rules_list")],
|
||||
[InlineKeyboardButton(text="➕ Добавить страницу", callback_data="admin_rules_create")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_rules_list_keyboard(rules, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
for rule in rules:
|
||||
status_emoji = "🟢" if rule.is_active else "🔴"
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {rule.page_order}. {rule.title}",
|
||||
callback_data=f"admin_rule_view_{rule.id}"
|
||||
)
|
||||
])
|
||||
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="➕ Добавить страницу", callback_data="admin_rules_create")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_rules")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_rule_edit_keyboard(rule_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать заголовок", callback_data=f"admin_rule_edit_title_{rule_id}"),
|
||||
InlineKeyboardButton(text="📝 Редактировать содержимое", callback_data=f"admin_rule_edit_content_{rule_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Изменить порядок", callback_data=f"admin_rule_edit_order_{rule_id}"),
|
||||
InlineKeyboardButton(text="🟢/🔴 Вкл/Выкл", callback_data=f"admin_rule_toggle_{rule_id}")
|
||||
],
|
||||
[InlineKeyboardButton(text="🗑 Удалить", callback_data=f"admin_rule_delete_{rule_id}")],
|
||||
[InlineKeyboardButton(text="🔙 К списку", callback_data="admin_rules_list")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_rule_delete_confirm_keyboard(rule_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"admin_rule_confirm_delete_{rule_id}"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_rule_view_{rule_id}")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def autopay_settings_keyboard(user_sub_id: int, user_sub, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if user_sub.auto_pay_enabled:
|
||||
toggle_text = "❌ Отключить автоплатеж"
|
||||
toggle_callback = f"toggle_autopay_{user_sub_id}"
|
||||
else:
|
||||
toggle_text = "✅ Включить автоплатеж"
|
||||
toggle_callback = f"toggle_autopay_{user_sub_id}"
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=toggle_text, callback_data=toggle_callback)])
|
||||
|
||||
if user_sub.auto_pay_enabled:
|
||||
buttons.append([InlineKeyboardButton(text="📅 Настроить дни до продления", callback_data="noop")])
|
||||
|
||||
days_row = []
|
||||
for days in [1, 3, 5, 7]:
|
||||
emoji = "🔹" if user_sub.auto_pay_days_before == days else "⚪"
|
||||
days_row.append(InlineKeyboardButton(
|
||||
text=f"{emoji} {days}д",
|
||||
callback_data=f"autopay_days_{user_sub_id}_{days}"
|
||||
))
|
||||
buttons.append(days_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 К подписке", callback_data=f"view_sub_{user_sub_id}")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def autopay_status_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="autopay_status")],
|
||||
[InlineKeyboardButton(text="🚀 Принудительная проверка", callback_data="autopay_force_check")],
|
||||
[InlineKeyboardButton(text="📋 Список подписок", callback_data="autopay_subscriptions_list")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_autopay")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def autopay_subscriptions_keyboard(subscriptions_data: List[Dict], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
expired = [s for s in subscriptions_data if s['expires_in_days'] <= 0]
|
||||
due_soon = [s for s in subscriptions_data if 0 < s['expires_in_days'] <= s['auto_pay_days_before']]
|
||||
|
||||
critical_subs = expired + due_soon
|
||||
|
||||
for sub_data in critical_subs[:8]:
|
||||
username = sub_data['username'] if sub_data['username'] != 'N/A' else f"ID:{sub_data['user_id']}"
|
||||
days = sub_data['expires_in_days']
|
||||
|
||||
if days <= 0:
|
||||
status_emoji = "❌"
|
||||
status_text = f"Истекла"
|
||||
elif days <= sub_data['auto_pay_days_before']:
|
||||
status_emoji = "⚠️"
|
||||
status_text = f"Через {days}д"
|
||||
else:
|
||||
status_emoji = "✅"
|
||||
status_text = f"Через {days}д"
|
||||
|
||||
button_text = f"{status_emoji} @{username} ({status_text})"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"autopay_user_detail_{sub_data['user_id']}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(subscriptions_data) > 8:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"... и еще {len(subscriptions_data) - 8}",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="autopay_subscriptions_list")],
|
||||
[InlineKeyboardButton(text="📊 Статистика", callback_data="autopay_statistics")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_autopay")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def autopay_user_detail_keyboard(user_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Добавить баланс", callback_data=f"admin_add_balance_to_{user_id}")],
|
||||
[InlineKeyboardButton(text="📋 Управление подписками", callback_data=f"admin_user_subscriptions_{user_id}")],
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data=f"autopay_user_detail_{user_id}")],
|
||||
[InlineKeyboardButton(text="🔙 К списку", callback_data="autopay_subscriptions_list")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
|
||||
def autopay_statistics_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⚠️ Недостаточно средств", callback_data="autopay_insufficient_balance_users")],
|
||||
[InlineKeyboardButton(text="📋 Список подписок", callback_data="autopay_subscriptions_list")],
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="autopay_statistics")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_autopay")]
|
||||
])
|
||||
return keyboard
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
import random
|
||||
from typing import Optional, List
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from database import Database, User
|
||||
from keyboards import back_keyboard
|
||||
from translations import t
|
||||
from utils import log_user_action, format_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LuckyGameStates(StatesGroup):
|
||||
waiting_number_choice = State()
|
||||
|
||||
lucky_game_router = Router()
|
||||
|
||||
|
||||
@lucky_game_router.callback_query(F.data == "lucky_game")
|
||||
async def lucky_game_menu_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
reward_amount = getattr(config, 'LUCKY_GAME_REWARD', 50.0)
|
||||
numbers_count = getattr(config, 'LUCKY_GAME_NUMBERS', 30)
|
||||
winning_numbers = getattr(config, 'LUCKY_GAME_WINNING_COUNT', 3)
|
||||
|
||||
can_play, next_game_time = await check_can_play_today(db, user.telegram_id)
|
||||
|
||||
games_played, total_won, win_count = await get_user_game_stats(db, user.telegram_id)
|
||||
|
||||
text = "🎰 **Проверь свою удачу!**\n\n"
|
||||
text += "🎯 **Как играть:**\n"
|
||||
text += f"• Выберите число от 1 до {numbers_count}\n"
|
||||
text += f"• Из {numbers_count} чисел {winning_numbers} - выигрышные\n"
|
||||
text += f"• Угадали - получаете {reward_amount:.0f}₽ на баланс!\n"
|
||||
text += "• Играть можно 1 раз в сутки\n\n"
|
||||
|
||||
text += "📊 **Ваша статистика:**\n"
|
||||
text += f"• Игр сыграно: {games_played}\n"
|
||||
text += f"• Выигрышей: {win_count}\n"
|
||||
text += f"• Всего выиграно: {total_won:.0f}₽\n"
|
||||
|
||||
if games_played > 0:
|
||||
win_rate = (win_count / games_played) * 100
|
||||
text += f"• Процент побед: {win_rate:.1f}%\n"
|
||||
|
||||
buttons = []
|
||||
|
||||
if can_play:
|
||||
buttons.append([InlineKeyboardButton(text="🎲 Играть!", callback_data="start_lucky_game")])
|
||||
else:
|
||||
now = datetime.utcnow()
|
||||
time_left = next_game_time - now
|
||||
hours_left = int(time_left.total_seconds() // 3600)
|
||||
minutes_left = int((time_left.total_seconds() % 3600) // 60)
|
||||
|
||||
if hours_left > 0:
|
||||
time_text = f"{hours_left}ч {minutes_left}м"
|
||||
else:
|
||||
time_text = f"{minutes_left}м"
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=f"⏰ Приходи через {time_text}", callback_data="noop")])
|
||||
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="📈 История игр", callback_data="lucky_game_history")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', user.language), callback_data="main_menu")]
|
||||
])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in lucky game menu: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки игры")
|
||||
|
||||
@lucky_game_router.callback_query(F.data == "start_lucky_game")
|
||||
async def start_lucky_game_callback(callback: CallbackQuery, db: Database, state: FSMContext, **kwargs):
|
||||
"""Начать игру - показать числа для выбора"""
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
can_play, next_game_time = await check_can_play_today(db, user.telegram_id)
|
||||
|
||||
if not can_play:
|
||||
await callback.answer("⏰ Вы уже играли сегодня! Приходите завтра.", show_alert=True)
|
||||
return
|
||||
|
||||
numbers_count = getattr(config, 'LUCKY_GAME_NUMBERS', 30)
|
||||
winning_numbers = getattr(config, 'LUCKY_GAME_WINNING_COUNT', 3)
|
||||
reward_amount = getattr(config, 'LUCKY_GAME_REWARD', 50.0)
|
||||
|
||||
winning_nums = random.sample(range(1, numbers_count + 1), winning_numbers)
|
||||
|
||||
await state.update_data(
|
||||
winning_numbers=winning_nums,
|
||||
reward_amount=reward_amount,
|
||||
numbers_count=numbers_count
|
||||
)
|
||||
await state.set_state(LuckyGameStates.waiting_number_choice)
|
||||
|
||||
text = f"🎯 **Выберите число от 1 до {numbers_count}**\n\n"
|
||||
text += f"🎁 Награда: {reward_amount:.0f}₽\n"
|
||||
text += f"🍀 Удачных чисел: {winning_numbers} из {numbers_count}\n\n"
|
||||
text += "Нажмите на число, чтобы испытать удачу!"
|
||||
|
||||
buttons = []
|
||||
for i in range(0, numbers_count, 5):
|
||||
row = []
|
||||
for j in range(5):
|
||||
if i + j + 1 <= numbers_count:
|
||||
number = i + j + 1
|
||||
row.append(InlineKeyboardButton(
|
||||
text=str(number),
|
||||
callback_data=f"choose_number_{number}"
|
||||
))
|
||||
buttons.append(row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="❌ Отмена", callback_data="lucky_game")])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting lucky game: {e}")
|
||||
await callback.answer("❌ Ошибка запуска игры")
|
||||
|
||||
@lucky_game_router.callback_query(F.data.startswith("choose_number_"), StateFilter(LuckyGameStates.waiting_number_choice))
|
||||
async def choose_number_callback(callback: CallbackQuery, db: Database, state: FSMContext, **kwargs):
|
||||
"""Обработка выбора числа"""
|
||||
user = kwargs.get('user')
|
||||
bot = kwargs.get('bot')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
chosen_number = int(callback.data.split("_")[2])
|
||||
|
||||
state_data = await state.get_data()
|
||||
winning_numbers = state_data['winning_numbers']
|
||||
reward_amount = state_data['reward_amount']
|
||||
numbers_count = state_data['numbers_count']
|
||||
|
||||
is_winner = chosen_number in winning_numbers
|
||||
|
||||
await save_game_result(db, user.telegram_id, chosen_number, winning_numbers, is_winner, reward_amount if is_winner else 0.0)
|
||||
|
||||
if is_winner:
|
||||
await db.add_balance(user.telegram_id, reward_amount)
|
||||
|
||||
await db.create_payment(
|
||||
user_id=user.telegram_id,
|
||||
amount=reward_amount,
|
||||
payment_type='lucky_game',
|
||||
description=f'Выигрыш в игре удачи (число {chosen_number})',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
text = "🎉 **ПОЗДРАВЛЯЕМ! ВЫ ВЫИГРАЛИ!** 🎉\n\n"
|
||||
text += f"🎯 Ваше число: **{chosen_number}**\n"
|
||||
text += f"💰 Награда: **{reward_amount:.0f}₽** зачислена на баланс!\n"
|
||||
text += f"🆕 Новый баланс: **{user.balance + reward_amount:.0f}₽**\n\n"
|
||||
else:
|
||||
text = "😔 **Не повезло в этот раз...**\n\n"
|
||||
text += f"🎯 Ваше число: **{chosen_number}**\n"
|
||||
text += f"🍀 Выигрышные числа: **{', '.join(map(str, sorted(winning_numbers)))}**\n\n"
|
||||
text += "🔄 Попробуйте завтра!"
|
||||
|
||||
winning_nums_str = ', '.join(map(str, sorted(winning_numbers)))
|
||||
text += f"\n🎲 Сегодняшние удачные числа: {winning_nums_str}"
|
||||
text += f"\n\n⏰ Следующая игра будет доступна завтра!"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📈 История игр", callback_data="lucky_game_history")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
log_user_action(user.telegram_id, "lucky_game_played",
|
||||
f"Number: {chosen_number}, Winner: {is_winner}, Reward: {reward_amount if is_winner else 0}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in choose number: {e}")
|
||||
await callback.answer("❌ Ошибка обработки выбора")
|
||||
|
||||
await state.clear()
|
||||
|
||||
@lucky_game_router.callback_query(F.data == "lucky_game_history")
|
||||
async def lucky_game_history_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
games = await get_user_game_history(db, user.telegram_id, limit=10)
|
||||
|
||||
if not games:
|
||||
text = "📈 **История ваших игр**\n\n"
|
||||
text += "🎯 Вы еще не играли в игру удачи.\n\n"
|
||||
text += "Начните играть, чтобы увидеть историю!"
|
||||
else:
|
||||
text = "📈 **История ваших игр** (последние 10)\n\n"
|
||||
|
||||
for i, game in enumerate(games, 1):
|
||||
date_str = format_datetime(game['played_at'], user.language)
|
||||
|
||||
if game['is_winner']:
|
||||
emoji = "🎉"
|
||||
result = f"Выиграли {game['reward_amount']:.0f}₽"
|
||||
else:
|
||||
emoji = "😔"
|
||||
result = "Не повезло"
|
||||
|
||||
text += f"{i}. {emoji} Число: **{game['chosen_number']}** - {result}\n"
|
||||
text += f" 📅 {date_str}\n\n"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎰 К игре", callback_data="lucky_game")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error showing game history: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки истории")
|
||||
|
||||
@lucky_game_router.callback_query(F.data == "noop")
|
||||
async def noop_callback(callback: CallbackQuery, **kwargs):
|
||||
"""Пустая операция для неактивных кнопок"""
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def check_can_play_today(db: Database, user_id: int) -> tuple[bool, Optional[datetime]]:
|
||||
"""Проверяет, может ли пользователь играть сегодня"""
|
||||
try:
|
||||
can_play = await db.can_play_lucky_game_today(user_id)
|
||||
|
||||
if not can_play:
|
||||
now = datetime.utcnow()
|
||||
tomorrow = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
return False, tomorrow
|
||||
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking can play today: {e}")
|
||||
return True, None
|
||||
|
||||
async def get_user_game_stats(db: Database, user_id: int) -> tuple[int, float, int]:
|
||||
"""Получает статистику игр пользователя"""
|
||||
try:
|
||||
stats = await db.get_user_game_stats(user_id)
|
||||
return stats['total_games'], stats['total_won'], stats['total_wins']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user game stats: {e}")
|
||||
return 0, 0.0, 0
|
||||
|
||||
async def save_game_result(db: Database, user_id: int, chosen_number: int,
|
||||
winning_numbers: List[int], is_winner: bool, reward_amount: float):
|
||||
"""Сохраняет результат игры в базу данных"""
|
||||
try:
|
||||
game = await db.create_lucky_game(
|
||||
user_id=user_id,
|
||||
chosen_number=chosen_number,
|
||||
winning_numbers=winning_numbers,
|
||||
is_winner=is_winner,
|
||||
reward_amount=reward_amount
|
||||
)
|
||||
|
||||
if game:
|
||||
logger.info(f"Game result saved for user {user_id}: number={chosen_number}, winner={is_winner}, reward={reward_amount}")
|
||||
else:
|
||||
logger.error(f"Failed to save game result for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving game result: {e}")
|
||||
|
||||
async def get_user_game_history(db: Database, user_id: int, limit: int = 10) -> List[dict]:
|
||||
"""Получает историю игр пользователя"""
|
||||
try:
|
||||
return await db.get_user_game_history(user_id, limit)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user game history: {e}")
|
||||
return []
|
||||
@@ -2,350 +2,217 @@ import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from lucky_game import lucky_game_router
|
||||
from stars_handlers import stars_router
|
||||
from autopay_service import AutoPayService
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
print("🚀 Запуск бота...")
|
||||
print(f"📍 Рабочая директория: {os.getcwd()}")
|
||||
print(f"📁 Файлы в директории: {os.listdir('.')}")
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
if os.path.exists('.env'):
|
||||
print("✅ Файл .env найден")
|
||||
else:
|
||||
print("❌ Файл .env НЕ НАЙДЕН!")
|
||||
print("💡 Создайте файл .env в корне проекта")
|
||||
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
|
||||
|
||||
from config import load_config, debug_environment
|
||||
from database import Database
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from subscription_monitor import create_subscription_monitor
|
||||
from middlewares import DatabaseMiddleware, UserMiddleware, LoggingMiddleware, ThrottlingMiddleware, WorkflowDataMiddleware, BotMiddleware
|
||||
from handlers import router
|
||||
from admin_handlers import admin_router
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('bot.log'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BotApplication:
|
||||
class GracefulExit:
|
||||
|
||||
def __init__(self):
|
||||
self.config = None
|
||||
self.db = None
|
||||
self.api = None
|
||||
self.bot = None
|
||||
self.dp = None
|
||||
self.monitor_service = None
|
||||
self.autopay_service = None
|
||||
self.exit = False
|
||||
|
||||
def exit_gracefully(self, signum, frame):
|
||||
logging.getLogger(__name__).info(f"Получен сигнал {signum}. Корректное завершение работы...")
|
||||
self.exit = True
|
||||
|
||||
async def _init_autopay_service(self):
|
||||
"""Инициализирует сервис автоплатежей"""
|
||||
try:
|
||||
logger.info("🔧 Initializing autopay service...")
|
||||
|
||||
if not self.bot:
|
||||
logger.error("❌ Bot instance is None, cannot initialize autopay")
|
||||
return
|
||||
|
||||
if not self.db:
|
||||
logger.error("❌ Database instance is None, cannot initialize autopay")
|
||||
return
|
||||
|
||||
self.autopay_service = AutoPayService(self.db, self.api, self.bot)
|
||||
|
||||
self.dp.workflow_data["autopay_service"] = self.autopay_service
|
||||
logger.info("✅ Autopay service added to workflow_data")
|
||||
|
||||
logger.info("🚀 Starting autopay service...")
|
||||
await self.autopay_service.start()
|
||||
|
||||
status = await self.autopay_service.get_service_status()
|
||||
if status['is_running']:
|
||||
logger.info("✅ Autopay service started successfully")
|
||||
logger.info(f"📊 Autopay status: interval=30min")
|
||||
else:
|
||||
logger.warning("⚠️ Autopay service created but not running")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize autopay service: {e}", exc_info=True)
|
||||
logger.warning("⚠️ Continuing without autopay service")
|
||||
self.autopay_service = None
|
||||
|
||||
async def initialize(self):
|
||||
|
||||
debug_environment()
|
||||
|
||||
self.config = load_config()
|
||||
|
||||
print(f"🔧 Загруженная конфигурация:")
|
||||
print(f" BOT_USERNAME: '{self.config.BOT_USERNAME}'")
|
||||
print(f" REFERRAL_FIRST_REWARD: {self.config.REFERRAL_FIRST_REWARD}")
|
||||
print(f" ADMIN_IDS: {self.config.ADMIN_IDS}")
|
||||
|
||||
if not self.config.BOT_TOKEN:
|
||||
logger.error("BOT_TOKEN is required")
|
||||
raise ValueError("BOT_TOKEN is required")
|
||||
|
||||
if not self.config.REMNAWAVE_URL or not self.config.REMNAWAVE_TOKEN:
|
||||
logger.error("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
|
||||
raise ValueError("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
|
||||
|
||||
if not self.config.BOT_USERNAME:
|
||||
logger.warning("⚠️ BOT_USERNAME не установлен! Реферальные ссылки работать не будут!")
|
||||
print("💡 Добавьте BOT_USERNAME=your_bot_username в .env файл")
|
||||
|
||||
logger.info("Starting RemnaWave Bot...")
|
||||
logger.info(f"RemnaWave URL: {self.config.REMNAWAVE_URL}")
|
||||
logger.info(f"Admin IDs: {self.config.ADMIN_IDS}")
|
||||
logger.info(f"Bot Username: {self.config.BOT_USERNAME}")
|
||||
|
||||
self.db = Database(self.config.DATABASE_URL)
|
||||
await self._init_database()
|
||||
|
||||
self.api = RemnaWaveAPI(
|
||||
self.config.REMNAWAVE_URL,
|
||||
self.config.REMNAWAVE_TOKEN,
|
||||
self.config.SUBSCRIPTION_BASE_URL
|
||||
)
|
||||
logger.info("RemnaWave API initialized")
|
||||
|
||||
await self._test_api_connection()
|
||||
|
||||
self.bot = Bot(
|
||||
token=self.config.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
await self._test_bot_token()
|
||||
|
||||
self._setup_dispatcher()
|
||||
|
||||
await self._init_monitor_service()
|
||||
await self._init_autopay_service()
|
||||
|
||||
if self.config.STARS_ENABLED:
|
||||
logger.info("✅ Telegram Stars пополнение включено")
|
||||
logger.info(f"📊 Настроенные курсы: {self.config.STARS_RATES}")
|
||||
|
||||
if not self.config.STARS_RATES or len(self.config.STARS_RATES) == 0:
|
||||
logger.warning("⚠️ STARS_RATES пустые! Telegram Stars будут недоступны")
|
||||
self.config.STARS_ENABLED = False
|
||||
else:
|
||||
valid_rates = all(
|
||||
isinstance(stars, int) and isinstance(rubles, (int, float)) and stars > 0 and rubles > 0
|
||||
for stars, rubles in self.config.STARS_RATES.items()
|
||||
)
|
||||
if not valid_rates:
|
||||
logger.warning("⚠️ Неверные STARS_RATES! Telegram Stars будут недоступны")
|
||||
self.config.STARS_ENABLED = False
|
||||
else:
|
||||
logger.info("❌ Telegram Stars пополнение отключено")
|
||||
|
||||
async def _init_database(self):
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
await self.db.init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization attempt {attempt + 1} failed: {e}")
|
||||
if attempt == max_retries - 1:
|
||||
logger.error("Failed to initialize database after all retries")
|
||||
raise
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def _test_api_connection(self):
|
||||
try:
|
||||
system_stats = await self.api.get_system_stats()
|
||||
if system_stats:
|
||||
logger.info("RemnaWave API connection successful")
|
||||
else:
|
||||
logger.warning("RemnaWave API connection test failed - continuing anyway")
|
||||
except Exception as e:
|
||||
logger.warning(f"RemnaWave API connection error: {e} - continuing anyway")
|
||||
|
||||
async def _test_bot_token(self):
|
||||
try:
|
||||
bot_info = await self.bot.get_me()
|
||||
logger.info(f"Bot started: @{bot_info.username} ({bot_info.first_name})")
|
||||
|
||||
if not self.config.BOT_USERNAME and bot_info.username:
|
||||
self.config.BOT_USERNAME = bot_info.username
|
||||
logger.info(f"✅ BOT_USERNAME автоматически установлен: {bot_info.username}")
|
||||
print("💡 Добавьте BOT_USERNAME в .env файл для постоянного сохранения")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid bot token or network error: {e}")
|
||||
raise
|
||||
|
||||
def _setup_dispatcher(self):
|
||||
storage = MemoryStorage()
|
||||
self.dp = Dispatcher(storage=storage)
|
||||
|
||||
self.dp.workflow_data.update({
|
||||
"config": self.config,
|
||||
"api": self.api,
|
||||
"db": self.db,
|
||||
"monitor_service": None
|
||||
})
|
||||
|
||||
self.dp.message.middleware(LoggingMiddleware())
|
||||
self.dp.callback_query.middleware(LoggingMiddleware())
|
||||
|
||||
self.dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
|
||||
self.dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
|
||||
|
||||
self.dp.message.middleware(WorkflowDataMiddleware())
|
||||
self.dp.callback_query.middleware(WorkflowDataMiddleware())
|
||||
|
||||
self.dp.message.middleware(BotMiddleware(self.bot))
|
||||
self.dp.callback_query.middleware(BotMiddleware(self.bot))
|
||||
|
||||
self.dp.message.middleware(DatabaseMiddleware(self.db))
|
||||
self.dp.callback_query.middleware(DatabaseMiddleware(self.db))
|
||||
|
||||
self.dp.message.middleware(UserMiddleware(self.db, self.config))
|
||||
self.dp.callback_query.middleware(UserMiddleware(self.db, self.config))
|
||||
|
||||
self.dp.include_router(router)
|
||||
self.dp.include_router(admin_router)
|
||||
self.dp.include_router(lucky_game_router)
|
||||
self.dp.include_router(stars_router)
|
||||
|
||||
async def _init_monitor_service(self):
|
||||
try:
|
||||
logger.info("🔧 Initializing subscription monitor service...")
|
||||
|
||||
if not self.bot:
|
||||
logger.error("❌ Bot instance is None, cannot initialize monitor")
|
||||
return
|
||||
|
||||
if not self.db:
|
||||
logger.error("❌ Database instance is None, cannot initialize monitor")
|
||||
return
|
||||
|
||||
if not self.config:
|
||||
logger.error("❌ Config instance is None, cannot initialize monitor")
|
||||
return
|
||||
|
||||
self.monitor_service = await create_subscription_monitor(
|
||||
self.bot, self.db, self.config, self.api
|
||||
)
|
||||
|
||||
if not self.monitor_service:
|
||||
logger.error("❌ Failed to create monitor service instance")
|
||||
return
|
||||
|
||||
self.dp.workflow_data["monitor_service"] = self.monitor_service
|
||||
logger.info("✅ Monitor service added to workflow_data")
|
||||
|
||||
logger.info("🚀 Starting monitor service...")
|
||||
await self.monitor_service.start()
|
||||
|
||||
status = await self.monitor_service.get_service_status()
|
||||
if status['is_running']:
|
||||
logger.info("✅ Subscription monitor service started successfully")
|
||||
logger.info(f"📊 Monitor status: interval={status['check_interval']}s, daily_hour={status['daily_check_hour']}, warning_days={status['warning_days']}")
|
||||
else:
|
||||
logger.warning("⚠️ Monitor service created but not running")
|
||||
logger.warning(f"📊 Monitor status: {status}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize monitor service: {e}", exc_info=True)
|
||||
logger.warning("⚠️ Continuing without monitor service")
|
||||
self.monitor_service = None
|
||||
|
||||
async def start(self):
|
||||
logger.info("Bot polling started successfully")
|
||||
|
||||
if self.config.BOT_USERNAME:
|
||||
logger.info(f"🎁 Реферальная система активна! Ссылки: https://t.me/{self.config.BOT_USERNAME}?start=ref_USERID")
|
||||
else:
|
||||
logger.warning("⚠️ Реферальная система неактивна! Установите BOT_USERNAME")
|
||||
|
||||
try:
|
||||
await self.dp.start_polling(self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during polling: {e}")
|
||||
raise
|
||||
finally:
|
||||
await self.shutdown()
|
||||
|
||||
async def shutdown(self):
|
||||
logger.info("Shutting down bot...")
|
||||
|
||||
if self.autopay_service:
|
||||
try:
|
||||
await self.autopay_service.stop()
|
||||
logger.info("Autopay service stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping autopay service: {e}")
|
||||
|
||||
if self.monitor_service:
|
||||
try:
|
||||
await self.monitor_service.stop()
|
||||
logger.info("Monitor service stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping monitor service: {e}")
|
||||
|
||||
if self.api:
|
||||
try:
|
||||
await self.api.close()
|
||||
logger.info("API connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing API: {e}")
|
||||
|
||||
if self.db:
|
||||
try:
|
||||
await self.db.close()
|
||||
logger.info("Database connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing database: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.session.close()
|
||||
logger.info("Bot session closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing bot session: {e}")
|
||||
|
||||
logger.info("Bot shutdown complete")
|
||||
|
||||
async def main():
|
||||
app = None
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(settings.LOG_FILE, encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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:
|
||||
app = BotApplication()
|
||||
await app.initialize()
|
||||
await app.start()
|
||||
logger.info("📊 Инициализация базы данных...")
|
||||
await init_db()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Bot stopped by user (Ctrl+C)")
|
||||
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)
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info("🌐 Запуск Tribute webhook сервера...")
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
else:
|
||||
logger.info("ℹ️ Tribute отключен, webhook сервер не запускается")
|
||||
|
||||
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:
|
||||
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}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Critical error in main: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.error(f"❌ Критическая ошибка при запуске: {e}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
if app:
|
||||
await app.shutdown()
|
||||
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__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Bot stopped by user")
|
||||
print("\n🛑 Бот остановлен пользователем")
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error: {e}")
|
||||
print(f"❌ Критическая ошибка: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, User as TgUser, Message, CallbackQuery
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
import logging
|
||||
from database import Database, User
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DatabaseMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
data['db'] = self.db
|
||||
return await handler(event, data)
|
||||
|
||||
class UserMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self, db: Database, config: Config):
|
||||
self.db = db
|
||||
self.config = config
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_user: TgUser = data.get('event_from_user')
|
||||
|
||||
if telegram_user and not telegram_user.is_bot:
|
||||
try:
|
||||
user = await self.db.get_user_by_telegram_id(telegram_user.id)
|
||||
|
||||
if not user:
|
||||
is_admin = telegram_user.id in self.config.ADMIN_IDS
|
||||
|
||||
user = await self.db.create_user(
|
||||
telegram_id=telegram_user.id,
|
||||
username=telegram_user.username,
|
||||
first_name=telegram_user.first_name,
|
||||
last_name=telegram_user.last_name,
|
||||
language='',
|
||||
is_admin=is_admin
|
||||
)
|
||||
logger.info(f"Created new user: {telegram_user.id} without language")
|
||||
else:
|
||||
updated = False
|
||||
if user.username != telegram_user.username:
|
||||
user.username = telegram_user.username
|
||||
updated = True
|
||||
if user.first_name != telegram_user.first_name:
|
||||
user.first_name = telegram_user.first_name
|
||||
updated = True
|
||||
if user.last_name != telegram_user.last_name:
|
||||
user.last_name = telegram_user.last_name
|
||||
updated = True
|
||||
|
||||
should_be_admin = telegram_user.id in self.config.ADMIN_IDS
|
||||
if user.is_admin != should_be_admin:
|
||||
user.is_admin = should_be_admin
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
await self.db.update_user(user)
|
||||
|
||||
data['user'] = user
|
||||
data['lang'] = user.language if user.language and user.language != '' else self.config.DEFAULT_LANGUAGE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in UserMiddleware: {e}")
|
||||
class FallbackUser:
|
||||
def __init__(self, telegram_id: int, username: str = None, config: Config = None):
|
||||
self.telegram_id = telegram_id
|
||||
self.username = username
|
||||
self.first_name = None
|
||||
self.last_name = None
|
||||
self.language = config.DEFAULT_LANGUAGE if config else 'ru'
|
||||
self.balance = 0.0
|
||||
self.is_admin = telegram_id in (config.ADMIN_IDS if config else [])
|
||||
self.remnawave_uuid = None
|
||||
|
||||
try:
|
||||
fallback_user = FallbackUser(telegram_user.id, telegram_user.username, self.config)
|
||||
data['user'] = fallback_user
|
||||
data['lang'] = self.config.DEFAULT_LANGUAGE
|
||||
except:
|
||||
data['user'] = None
|
||||
data['lang'] = self.config.DEFAULT_LANGUAGE
|
||||
else:
|
||||
data['user'] = None
|
||||
data['lang'] = self.config.DEFAULT_LANGUAGE
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_user: TgUser = data.get('event_from_user')
|
||||
|
||||
if isinstance(event, Message):
|
||||
username = f"@{telegram_user.username}" if telegram_user and telegram_user.username else "no_username"
|
||||
user_id = telegram_user.id if telegram_user else "unknown"
|
||||
text = event.text or "no_text"
|
||||
logger.info(f"Message from {user_id} ({username}): {text}")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
username = f"@{telegram_user.username}" if telegram_user and telegram_user.username else "no_username"
|
||||
user_id = telegram_user.id if telegram_user else "unknown"
|
||||
callback_data = event.data or "no_data"
|
||||
logger.info(f"Callback from {user_id} ({username}): {callback_data}")
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self, rate_limit: float = 1.0):
|
||||
self.rate_limit = rate_limit
|
||||
self.user_last_action = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_user: TgUser = data.get('event_from_user')
|
||||
|
||||
if telegram_user:
|
||||
import time
|
||||
current_time = time.time()
|
||||
user_id = telegram_user.id
|
||||
|
||||
if user_id in self.user_last_action:
|
||||
if current_time - self.user_last_action[user_id] < self.rate_limit:
|
||||
logger.warning(f"Throttling user {user_id}")
|
||||
return
|
||||
|
||||
self.user_last_action[user_id] = current_time
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
class WorkflowDataMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
if hasattr(event, 'bot'):
|
||||
dp = getattr(event.bot, '_dispatcher', None)
|
||||
if dp and hasattr(dp, 'workflow_data'):
|
||||
workflow_data = dp.workflow_data
|
||||
for key, value in workflow_data.items():
|
||||
if key not in data:
|
||||
data[key] = value
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
class BotMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
data['bot'] = self.bot
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,67 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.database.models import Base
|
||||
from app.config import settings
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add_paid_price_to_subscription
|
||||
|
||||
Revision ID: 3d9b35c6bd8f
|
||||
Revises:
|
||||
Create Date: 2025-08-23 08:17:00.563340
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3d9b35c6bd8f'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('subscriptions', sa.Column('paid_price_kopeks', sa.Integer(), nullable=False, server_default='0'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('subscriptions', 'paid_price_kopeks')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,279 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from database import Database
|
||||
from database import ReferralProgram, ReferralEarning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def process_referral_rewards(user_id: int, amount: float, payment_id: int, db: Database, bot=None, payment_type: str = None):
|
||||
try:
|
||||
import os
|
||||
|
||||
if payment_type not in ['topup', 'admin_topup']:
|
||||
logger.debug(f"Skipping referral rewards for payment type: {payment_type}")
|
||||
return
|
||||
|
||||
if amount <= 0:
|
||||
logger.debug(f"Skipping referral rewards for non-positive amount: {amount}")
|
||||
return
|
||||
|
||||
threshold = float(os.getenv('REFERRAL_THRESHOLD', '300.0'))
|
||||
first_reward = float(os.getenv('REFERRAL_FIRST_REWARD', '150.0'))
|
||||
referred_bonus = float(os.getenv('REFERRAL_REFERRED_BONUS', '150.0'))
|
||||
percentage = float(os.getenv('REFERRAL_PERCENTAGE', '0.25'))
|
||||
|
||||
referral = await db.get_referral_by_referred_id(user_id)
|
||||
|
||||
if not referral:
|
||||
logger.debug(f"No referral found for user {user_id}")
|
||||
return
|
||||
|
||||
user = await db.get_user_by_telegram_id(user_id)
|
||||
if not user:
|
||||
logger.error(f"User {user_id} not found")
|
||||
return
|
||||
|
||||
logger.info(f"Processing referral rewards for topup: user {user_id}, amount {amount}, referrer {referral.referrer_id}")
|
||||
|
||||
if not referral.first_reward_paid and user.balance >= threshold:
|
||||
logger.info(f"Processing first reward for referral {referral.id} (threshold: {threshold}, reward: {first_reward})")
|
||||
|
||||
await db.add_balance(referral.referrer_id, first_reward)
|
||||
|
||||
await db.create_payment(
|
||||
user_id=referral.referrer_id,
|
||||
amount=first_reward,
|
||||
payment_type='referral',
|
||||
description=f'Первая награда за реферала ID:{user_id}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
success = await db.create_referral_earning(
|
||||
referrer_id=referral.referrer_id,
|
||||
referred_id=user_id,
|
||||
amount=first_reward,
|
||||
earning_type='first_reward',
|
||||
related_payment_id=payment_id
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"First reward paid: {first_reward}₽ to referrer {referral.referrer_id}")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
await bot.send_message(
|
||||
referral.referrer_id,
|
||||
f"🎉 Поздравляем! Ваш реферал пополнил баланс на {threshold}₽+\n\n"
|
||||
f"💰 Вам начислено {first_reward}₽ за приведенного друга!\n"
|
||||
f"Теперь вы будете получать {percentage*100:.0f}% с каждого его пополнения баланса."
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
f"🎁 Бонус активирован! Вам начислено {referred_bonus}₽ за переход по реферальной ссылке!"
|
||||
)
|
||||
|
||||
await db.add_balance(user_id, referred_bonus)
|
||||
await db.create_payment(
|
||||
user_id=user_id,
|
||||
amount=referred_bonus,
|
||||
payment_type='referral',
|
||||
description='Бонус за переход по реферальной ссылке',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
logger.info(f"Referral bonus notifications sent and balance updated")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send referral notifications: {e}")
|
||||
else:
|
||||
logger.error(f"Failed to create first reward earning")
|
||||
|
||||
if referral.first_reward_paid:
|
||||
percentage_reward = amount * percentage
|
||||
|
||||
if percentage_reward >= 0.01:
|
||||
await db.add_balance(referral.referrer_id, percentage_reward)
|
||||
|
||||
await db.create_payment(
|
||||
user_id=referral.referrer_id,
|
||||
amount=percentage_reward,
|
||||
payment_type='referral',
|
||||
description=f'{percentage*100:.0f}% от пополнения реферала ID:{user_id}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
success = await db.create_referral_earning(
|
||||
referrer_id=referral.referrer_id,
|
||||
referred_id=user_id,
|
||||
amount=percentage_reward,
|
||||
earning_type='percentage',
|
||||
related_payment_id=payment_id
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Percentage reward paid: {percentage_reward:.2f}₽ ({percentage*100:.0f}%) to referrer {referral.referrer_id}")
|
||||
|
||||
if bot and percentage_reward >= 1.0:
|
||||
try:
|
||||
await bot.send_message(
|
||||
referral.referrer_id,
|
||||
f"💰 Реферальный доход!\n\n"
|
||||
f"Ваш реферал пополнил баланс на {amount:.2f}₽\n"
|
||||
f"Вам начислено: {percentage_reward:.2f}₽ ({percentage*100:.0f}%)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send percentage notification: {e}")
|
||||
else:
|
||||
logger.error(f"Failed to create percentage earning")
|
||||
else:
|
||||
logger.info(f"Skipping percentage reward for user {user_id} - first reward not yet paid")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing referral rewards: {e}")
|
||||
|
||||
async def create_referral_from_start_param(user_telegram_id: int, start_param: str, db: Database, bot=None):
|
||||
try:
|
||||
if not start_param.startswith("ref_"):
|
||||
return False
|
||||
|
||||
referrer_id = int(start_param.replace("ref_", ""))
|
||||
|
||||
if referrer_id == user_telegram_id:
|
||||
logger.warning(f"User {user_telegram_id} tried to refer themselves")
|
||||
return False
|
||||
|
||||
existing_reverse_referral = await db.get_referral_by_referred_id(referrer_id)
|
||||
if existing_reverse_referral and existing_reverse_referral.referrer_id == user_telegram_id:
|
||||
logger.warning(f"Mutual referral attempt blocked: {user_telegram_id} is already referrer for {referrer_id}")
|
||||
return False
|
||||
|
||||
existing_referral = await db.get_referral_by_referred_id(user_telegram_id)
|
||||
|
||||
if existing_referral:
|
||||
logger.info(f"User {user_telegram_id} already has referrer")
|
||||
return False
|
||||
|
||||
referral_code = await db.generate_unique_referral_code(referrer_id)
|
||||
|
||||
referral = await db.create_referral(referrer_id, user_telegram_id, referral_code)
|
||||
|
||||
if referral:
|
||||
logger.info(f"Created referral: {referrer_id} -> {user_telegram_id} with code {referral_code}")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
referrer = await db.get_user_by_telegram_id(referrer_id)
|
||||
if referrer:
|
||||
import os
|
||||
threshold = float(os.getenv('REFERRAL_THRESHOLD', '300.0'))
|
||||
first_reward = float(os.getenv('REFERRAL_FIRST_REWARD', '150.0'))
|
||||
percentage = float(os.getenv('REFERRAL_PERCENTAGE', '0.25'))
|
||||
|
||||
await bot.send_message(
|
||||
referrer_id,
|
||||
f"🎉 Отлично! По вашей ссылке зарегистрировался новый пользователь!\n\n"
|
||||
f"Вы получите {first_reward:.0f}₽ после того, как он пополнит баланс на {threshold:.0f}₽.\n"
|
||||
f"И будете получать {percentage*100:.0f}% с каждого его пополнения баланса!"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to notify referrer: {e}")
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid referral parameter: {start_param}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral from start param: {e}")
|
||||
return False
|
||||
|
||||
async def create_referral_from_promocode(user_telegram_id: int, referral_code: str, db: Database, bot=None):
|
||||
try:
|
||||
if not referral_code.startswith("REF"):
|
||||
return False
|
||||
|
||||
logger.info(f"Trying to use referral code {referral_code} for user {user_telegram_id}")
|
||||
|
||||
async with db.session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(ReferralProgram).where(ReferralProgram.referral_code == referral_code)
|
||||
)
|
||||
referral_record = result.scalar_one_or_none()
|
||||
|
||||
if not referral_record:
|
||||
logger.warning(f"No referrer found for code {referral_code}")
|
||||
return False
|
||||
|
||||
referrer_id = referral_record.referrer_id
|
||||
|
||||
if referrer_id == user_telegram_id:
|
||||
logger.warning(f"User {user_telegram_id} tried to use own referral code")
|
||||
return False
|
||||
|
||||
existing_reverse_referral = await db.get_referral_by_referred_id(referrer_id)
|
||||
if existing_reverse_referral and existing_reverse_referral.referrer_id == user_telegram_id:
|
||||
logger.warning(f"Mutual referral attempt blocked: {user_telegram_id} is already referrer for {referrer_id}")
|
||||
return False
|
||||
|
||||
existing_referral = await db.get_referral_by_referred_id(user_telegram_id)
|
||||
|
||||
if existing_referral:
|
||||
logger.info(f"User {user_telegram_id} already has referrer")
|
||||
return False
|
||||
|
||||
referral = await db.create_referral(referrer_id, user_telegram_id, referral_code)
|
||||
|
||||
if referral:
|
||||
logger.info(f"Created referral from promocode: {referrer_id} -> {user_telegram_id}")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
import os
|
||||
threshold = float(os.getenv('REFERRAL_THRESHOLD', '300.0'))
|
||||
first_reward = float(os.getenv('REFERRAL_FIRST_REWARD', '150.0'))
|
||||
|
||||
await bot.send_message(
|
||||
referrer_id,
|
||||
f"🎉 По вашему промокоду {referral_code} зарегистрировался новый пользователь!\n\n"
|
||||
f"Вы получите {first_reward:.0f}₽ после того, как он пополнит баланс на {threshold:.0f}₽."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to notify referrer: {e}")
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral from promocode: {e}")
|
||||
return False
|
||||
|
||||
def generate_referral_link(bot_username: str, user_id: int) -> str:
|
||||
if not bot_username:
|
||||
return ""
|
||||
|
||||
if bot_username.startswith('@'):
|
||||
bot_username = bot_username[1:]
|
||||
|
||||
return f"https://t.me/{bot_username}?start=ref_{user_id}"
|
||||
|
||||
def validate_referral_code(code: str) -> bool:
|
||||
if not code or not code.startswith("REF"):
|
||||
return False
|
||||
if len(code) < 4 or len(code) > 20:
|
||||
return False
|
||||
return True
|
||||
|
||||
def format_referral_stats(stats: dict, lang: str = 'ru') -> str:
|
||||
if lang == 'ru':
|
||||
return (f"👥 Приглашено: {stats['total_referrals']}\n"
|
||||
f"✅ Активных: {stats['active_referrals']}\n"
|
||||
f"💰 Заработано: {stats['total_earned']:.2f}₽")
|
||||
else:
|
||||
return (f"👥 Invited: {stats['total_referrals']}\n"
|
||||
f"✅ Active: {stats['active_referrals']}\n"
|
||||
f"💰 Earned: ${stats['total_earned']:.2f}")
|
||||
-1266
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user