Compare commits

...

238 Commits

Author SHA1 Message Date
Egor 6df92bde2a Update README.md 2025-09-02 00:34:13 +03:00
Egor 60ff006fec Update README.md 2025-09-02 00:33:03 +03:00
Egor 0181f5e10c Update docker-compose.yml 2025-09-02 00:30:33 +03:00
Egor 4bbcc60bbb Update subscriptions.py 2025-09-02 00:16:07 +03:00
Egor 8e2138e2aa Update subscriptions.py 2025-09-02 00:14:45 +03:00
Egor 70b9e8c10d Update inline.py 2025-09-01 23:44:56 +03:00
Egor dc1eddb0df Update subscription.py 2025-09-01 23:44:02 +03:00
Egor 630551cc4b Update README.md 2025-09-01 23:17:46 +03:00
Egor 4f94b25182 Update docker-compose.yml 2025-09-01 22:50:17 +03:00
Egor d3b240eae3 Update Dockerfile 2025-09-01 22:49:41 +03:00
Egor e2883d6727 Update main.py 2025-09-01 22:29:47 +03:00
Egor 21757b37ab Update bot.py 2025-09-01 22:27:37 +03:00
Egor 9190857ffa Update maintenance_service.py 2025-09-01 22:27:08 +03:00
Egor a607443aa7 Update README.md 2025-09-01 19:59:39 +03:00
Egor 41969bc0c2 Update .env.example 2025-09-01 19:59:20 +03:00
Egor 0d443afb87 Update balance.py 2025-09-01 19:58:21 +03:00
Egor 3d85294d41 Update config.py 2025-09-01 19:55:23 +03:00
Egor 86896771a6 Update inline.py 2025-09-01 05:02:10 +03:00
Egor 42d52990fa Update README.md 2025-09-01 04:34:16 +03:00
Egor 6296c8257f Update README.md 2025-09-01 04:13:29 +03:00
Egor 14a40cb1b6 Create docker-compose.yml 2025-09-01 04:10:55 +03:00
Egor d0e3650de5 Update config.py 2025-09-01 03:52:56 +03:00
Egor df8ed666ae Update menu.py 2025-09-01 03:51:29 +03:00
Egor 2217b7f800 Update config.py 2025-09-01 03:46:13 +03:00
Egor 924febc2f3 Update menu.py 2025-09-01 03:43:27 +03:00
Egor 729ffb7952 Update messages.py 2025-09-01 01:06:54 +03:00
Egor 1568ca7633 Update README.md 2025-09-01 00:11:27 +03:00
Egor 7d5e476580 Update referral.py 2025-08-31 23:18:00 +03:00
Egor 9bcd187358 Update referral.py 2025-08-31 23:14:44 +03:00
Egor a9cbb6272f Update referral.py 2025-08-31 23:07:27 +03:00
Egor b524decfa7 Update referral.py 2025-08-31 23:04:00 +03:00
Egor 5ee01f7735 Update referral.py 2025-08-31 23:00:20 +03:00
Egor f46f2d6523 Update start.py 2025-08-31 22:45:54 +03:00
Egor 435dbf6cee Update auth.py 2025-08-31 22:42:02 +03:00
Egor 94d38eeb71 Update auth.py 2025-08-31 22:41:31 +03:00
Egor 0e7f6f90ed Update auth.py 2025-08-31 22:40:41 +03:00
Egor f3789287dc Update start.py 2025-08-31 22:40:04 +03:00
Egor a8312d7c10 Update promocode.py 2025-08-31 22:10:21 +03:00
Egor 612f4a7978 Update promocodes.py 2025-08-31 22:07:04 +03:00
Egor 2462347b5f Update promocode.py 2025-08-31 22:06:15 +03:00
Egor 3ec3c0d76b Update promocodes.py 2025-08-31 22:05:16 +03:00
Egor 8224aa98f4 Update promocodes.py 2025-08-31 22:02:00 +03:00
Egor bb9d9422fb Update promocode.py 2025-08-31 22:01:16 +03:00
Egor f3a184ff00 Update promocodes.py 2025-08-31 21:53:25 +03:00
Egor 866e4586df Update promocode.py 2025-08-31 21:52:18 +03:00
Egor a30647893d Update promocodes.py 2025-08-31 21:39:14 +03:00
Egor 73826312e5 Update promocodes.py 2025-08-31 19:08:16 +03:00
Egor dae34dc0ef Update promocodes.py 2025-08-31 18:59:39 +03:00
Egor 17d0c8c26f Update promocodes.py 2025-08-31 18:50:55 +03:00
Egor 67831dc162 Update promocodes.py 2025-08-31 18:45:16 +03:00
Egor ae84fbe141 Update promocodes.py 2025-08-31 18:36:54 +03:00
Egor 8c9bd890e8 Update promocodes.py 2025-08-31 18:35:57 +03:00
Egor e8c1956de5 Update promocodes.py 2025-08-31 18:28:06 +03:00
Egor 1540a41d3a Update .env.example 2025-08-31 17:40:08 +03:00
Egor 9cb61cb696 Update README.md 2025-08-31 17:39:52 +03:00
Egor 3511325285 Update auth.py 2025-08-31 16:18:42 +03:00
Egor 62507c5d28 Update auth.py 2025-08-31 16:10:28 +03:00
Egor 387df29a05 Update start.py 2025-08-31 15:40:31 +03:00
Egor 75dca7e1ad Update start.py 2025-08-31 15:32:25 +03:00
Egor 2d5e54d5ad Update README.md 2025-08-31 06:52:42 +03:00
Egor d62b2b0d6f Update README.md 2025-08-31 06:50:22 +03:00
Egor e273719ad1 Update README.md 2025-08-31 06:49:41 +03:00
Egor f6726f1c94 Update README.md 2025-08-31 06:15:32 +03:00
Egor 92ff4f661f Update README.md 2025-08-31 05:54:54 +03:00
Egor dfebc5e608 Update .gitignore 2025-08-31 05:52:00 +03:00
Egor 8e6121cd0b Update README.md 2025-08-31 05:48:08 +03:00
Egor 6c0d5714bd Update remnawave_service.py 2025-08-31 05:19:43 +03:00
Egor ed29697434 Update remnawave_service.py 2025-08-31 05:18:56 +03:00
Egor 07aebef82f Update remnawave_service.py 2025-08-31 05:16:17 +03:00
Egor 7ce390fd50 Update remnawave_service.py 2025-08-31 05:09:31 +03:00
Egor cebe392d1c Update remnawave_service.py 2025-08-31 04:55:19 +03:00
Egor fd160f585a Update remnawave_service.py 2025-08-31 04:50:31 +03:00
Egor 5c0516fbc0 Update remnawave_service.py 2025-08-31 04:45:25 +03:00
Egor ba075326c1 Update admin.py 2025-08-31 04:39:08 +03:00
Egor c32425341e Update remnawave.py 2025-08-31 04:38:20 +03:00
Egor cc8df1eff3 Update remnawave_service.py 2025-08-31 04:33:56 +03:00
Egor 49cda0791c Update .env.example 2025-08-31 04:25:10 +03:00
Egor 60ea363a62 Update README.md 2025-08-31 04:24:37 +03:00
Egor f66619b225 Update subscription.py 2025-08-31 04:19:18 +03:00
Egor 5999402e1a Update inline.py 2025-08-31 04:17:18 +03:00
Egor 3aebccc090 Update inline.py 2025-08-31 04:16:37 +03:00
Egor ed577dd51a Update config.py 2025-08-31 04:15:45 +03:00
Egor e517487596 Update main.py 2025-08-31 04:03:37 +03:00
Egor f9441909a4 Update balance.py 2025-08-31 02:42:33 +03:00
Egor f13a015bfa Update payment_service.py 2025-08-31 00:47:07 +03:00
Egor 18bf946464 Update yookassa_webhook.py 2025-08-31 00:39:04 +03:00
Egor e4c46b2488 Update yookassa_webhook.py 2025-08-31 00:33:37 +03:00
Egor e0539b116c Update yookassa_webhook.py 2025-08-31 00:28:04 +03:00
Egor acdf7af1d9 Update yookassa_webhook.py 2025-08-31 00:22:18 +03:00
Egor d5b7828079 Update yookassa_webhook.py 2025-08-31 00:12:53 +03:00
Egor 03be071edc Update webhook_server.py 2025-08-30 23:54:27 +03:00
Egor e8742f30a6 Update webhook_server.py 2025-08-30 23:51:25 +03:00
Egor 3d7727dbf2 Update webhook_server.py 2025-08-30 23:46:31 +03:00
Egor 5bff67860f Update tribute.py 2025-08-30 23:45:48 +03:00
Egor 429e967da2 Update tribute.py 2025-08-30 23:44:35 +03:00
Egor c3df3beeae Update tribute.py 2025-08-30 23:42:55 +03:00
Egor 4d664c15d7 Update yookassa_webhook.py 2025-08-30 23:37:58 +03:00
Egor 53be365e38 Update tribute.py 2025-08-30 23:36:58 +03:00
Egor 3c3ffed32e Update yookassa_webhook.py 2025-08-30 23:36:12 +03:00
Egor 015726b3a6 Update webhook_server.py 2025-08-30 23:35:20 +03:00
Egor 4a3267712b Update main.py 2025-08-30 23:34:38 +03:00
Egor 9b45aae410 Update main.py 2025-08-30 23:24:43 +03:00
Egor f43b391d0e Update referral_service.py 2025-08-30 23:22:13 +03:00
Egor b09b7a0c84 Add files via upload 2025-08-30 23:21:04 +03:00
Egor 541b44b933 Update payment_service.py 2025-08-30 19:32:55 +03:00
Egor b21fce0eff Update webhook_server.py 2025-08-30 19:23:58 +03:00
Egor 017aa27c0f Update webhook_server.py 2025-08-30 19:17:31 +03:00
Egor 496978e8a2 Update texts.py 2025-08-30 19:11:45 +03:00
Egor b3711a590d Update user.py 2025-08-30 19:08:50 +03:00
Egor 631a0f2c4c Update payment_service.py 2025-08-30 19:05:55 +03:00
Egor 503f7ca455 Update transaction.py 2025-08-30 19:01:34 +03:00
Egor 80c771b7bc Update texts.py 2025-08-30 18:59:08 +03:00
Egor 6e344d350b Update texts.py 2025-08-30 18:58:30 +03:00
Egor 7738bc83af Update user.py 2025-08-30 18:57:32 +03:00
Egor 6f7a1eecf6 Update transaction.py 2025-08-30 18:56:07 +03:00
Egor bf147a736a Update main.py 2025-08-30 18:51:43 +03:00
Egor ea3decf1ac Update payment_service.py 2025-08-30 18:49:30 +03:00
Egor b1c0315a6e Update Dockerfile 2025-08-30 18:46:10 +03:00
Egor f6fa7e8f41 Update main.py 2025-08-30 18:45:44 +03:00
Egor 33d4d7994c Update tribute.py 2025-08-30 18:44:45 +03:00
Egor 0ce4ce2b08 Update webhook_server.py 2025-08-30 18:43:32 +03:00
Egor 3a9163e919 Update maintenance_service.py 2025-08-30 17:58:52 +03:00
Egor 376cff748b Update subscription_service.py 2025-08-30 16:57:35 +03:00
Egor d5cef080da Update referral_service.py 2025-08-30 16:51:34 +03:00
Egor 33e31213b3 Update balance.py 2025-08-30 16:05:42 +03:00
Egor 8077ba0c35 Update maintenance_service.py 2025-08-30 15:50:01 +03:00
Egor 67ad3847f8 Update maintenance_service.py 2025-08-30 15:48:12 +03:00
Egor 1eda042b34 Update main.py 2025-08-30 15:40:32 +03:00
Egor 961ff46f43 Update maintenance_service.py 2025-08-30 15:39:43 +03:00
Egor 535ec34a1b Update maintenance_service.py 2025-08-30 15:15:42 +03:00
Egor fe474fd0bf Merge pull request #18 from Fr1ngg/MAINTENANCE_MODE
Maintenance mode
2025-08-30 14:50:04 +03:00
Egor bfc737c1d3 Update main.py 2025-08-30 14:47:40 +03:00
Egor ce0eb32d4e Update maintenance_service.py 2025-08-30 14:46:41 +03:00
Egor 090df2e3ea Update maintenance.py 2025-08-30 14:45:02 +03:00
Egor 01d9df2867 Update maintenance.py 2025-08-30 14:43:57 +03:00
Egor 334a14c80c Update bot.py 2025-08-30 14:41:55 +03:00
Egor 3d952570af Update README.md 2025-08-30 14:40:15 +03:00
Egor bf9567fe6f Update README.md 2025-08-30 14:37:22 +03:00
Egor 6c4c39b454 Add files via upload 2025-08-30 14:35:38 +03:00
Egor d39eb3f79f Update user.py 2025-08-30 13:00:32 +03:00
Egor 7f0ce61126 Update user.py 2025-08-30 05:00:44 +03:00
Egor 775056da6f Update user.py 2025-08-30 04:57:06 +03:00
Egor a15ecb375b Update user.py 2025-08-30 04:53:34 +03:00
Egor 3dea941c51 Update user_service.py 2025-08-30 04:49:50 +03:00
Egor 410f0aba95 Update user.py 2025-08-30 04:43:51 +03:00
Egor 1ec13fbf63 Update user.py 2025-08-30 04:39:57 +03:00
Egor 012408b7cd Update user.py 2025-08-30 04:36:23 +03:00
Egor fdca75b153 Update user.py 2025-08-30 04:35:19 +03:00
Egor 40c235cdac Update user_service.py 2025-08-30 04:26:52 +03:00
Egor 7ea8fa735c Update user.py 2025-08-30 04:26:25 +03:00
Egor 758141dbc4 Update user_service.py 2025-08-30 04:22:57 +03:00
Egor 3c37fa45e6 Update user_service.py 2025-08-30 04:17:01 +03:00
Egor 35a80f4585 Update user_service.py 2025-08-30 04:13:58 +03:00
Egor 57e033c7c6 Update config.py 2025-08-30 04:09:00 +03:00
Egor d05d070faa Update user_service.py 2025-08-30 04:07:09 +03:00
Egor 3f3829a745 Update tribute_service.py 2025-08-30 03:04:43 +03:00
Egor 21bb89e5ea Update transaction.py 2025-08-30 03:03:44 +03:00
Egor fad5ecbb89 Update tribute_service.py 2025-08-30 03:00:03 +03:00
Egor 2c64c57edd Update tribute_service.py 2025-08-30 02:53:15 +03:00
Egor 117c637756 Update user.py 2025-08-30 02:46:18 +03:00
Egor 488f161322 Update tribute_service.py 2025-08-30 02:45:00 +03:00
Egor a55ef684e6 Update user.py 2025-08-30 02:33:49 +03:00
Egor 90234ba01e Update tribute_service.py 2025-08-30 02:28:25 +03:00
Egor cd83361050 Update tribute_service.py 2025-08-30 02:22:02 +03:00
Egor 08cda481ba Update tribute.py 2025-08-30 02:17:47 +03:00
Egor 45f998d134 Update tribute.py 2025-08-29 19:46:07 +03:00
Egor 33ee5b4ec4 Update payment_service.py 2025-08-29 18:55:02 +03:00
Egor 545bbaafa5 Update balance.py 2025-08-29 18:38:59 +03:00
Egor cc957460bb Update balance.py 2025-08-29 18:21:32 +03:00
Egor cd51dab845 Update README.md 2025-08-29 12:29:43 +03:00
Egor 74923515cc Merge pull request #17 from Fr1ngg/YooKassa
YooKassa Интеграция
2025-08-29 11:59:37 +03:00
Egor b3f3c415df Update README.md 2025-08-29 11:56:46 +03:00
Egor 0b9de65cbc Update yookassa_service.py 2025-08-29 11:54:52 +03:00
Egor 1c26171b52 Update yookassa_webhook.py 2025-08-29 11:53:32 +03:00
Egor a6bc0afdd3 Update yookassa.py 2025-08-29 11:51:51 +03:00
Egor 966cfe6565 Update config.py 2025-08-29 11:50:12 +03:00
Egor d5b1c5805e Update requirements.txt 2025-08-29 11:48:31 +03:00
Egor a4ce5ff9ca Update .env.example 2025-08-29 11:47:46 +03:00
Egor 8e4b70578b Add files via upload 2025-08-29 11:46:33 +03:00
Egor 5367874062 Update models.py 2025-08-27 13:20:32 +03:00
Egor 321696b5b5 Update remnawave_api.py 2025-08-27 13:19:07 +03:00
Egor b642a5550c Update universal_migration.py 2025-08-27 13:16:13 +03:00
Egor 0e97632099 Update texts.py 2025-08-27 05:50:43 +03:00
Egor 4a3c6c4124 Update formatters.py 2025-08-27 05:50:18 +03:00
Egor 3bf9c143d4 Update monitoring_service.py 2025-08-27 05:49:44 +03:00
Egor 285e5e256e Update balance.py 2025-08-26 18:48:03 +03:00
Egor 5b06b89d7a bedolaga screens 2025-08-26 17:01:38 +03:00
Egor 7e749fa096 Merge pull request #16 from Fr1ngg/FixDbCollisions
Fix db collisions
2025-08-26 03:27:43 +03:00
Egor 13ac3f0e74 Create subscription_utils.py 2025-08-26 03:22:47 +03:00
Egor 32c551999c Update models.py 2025-08-26 03:21:26 +03:00
Egor ff2c09d3be Update subscription.py 2025-08-26 03:20:47 +03:00
Egor 4c97ac4497 Update subscription.py 2025-08-26 03:20:05 +03:00
Egor 86516186d9 Update main.py 2025-08-26 03:18:31 +03:00
Egor e0581bdc3c Create universal_migration.py 2025-08-26 03:14:26 +03:00
Egor 0b7d8d41f6 Update monitoring_service.py 2025-08-25 05:47:31 +03:00
Egor 4ca46537f9 Update subscription_service.py 2025-08-25 05:06:28 +03:00
Egor 75f2d0f1ce Update remnawave.py 2025-08-25 05:05:13 +03:00
Egor 51424bf256 Update user.py 2025-08-25 05:04:01 +03:00
Egor 4cce2f1efb Update remnawave_service.py 2025-08-25 05:03:15 +03:00
Egor 5102286963 Update bot.py 2025-08-25 03:17:27 +03:00
Egor 24c8f25e6e Update subscription.py 2025-08-25 03:16:39 +03:00
Egor de4796ac1c Update menu.py 2025-08-25 03:16:22 +03:00
Egor 95a8da5f19 Update monitoring_service.py 2025-08-25 03:15:45 +03:00
Egor 76f1313550 Update subscription_service.py 2025-08-25 03:15:02 +03:00
Egor 58d22200cf Create subscription_checker.py 2025-08-25 03:14:19 +03:00
Egor eb8b09e658 Update subscription.py 2025-08-25 03:12:46 +03:00
Egor 25cf635b6f Update models.py 2025-08-25 03:12:22 +03:00
Egor a54c1001f9 Update models.py 2025-08-25 03:11:49 +03:00
Egor 6140d7042a Update subscription.py 2025-08-24 23:22:56 +03:00
Egor d0135a2328 Update subscription.py 2025-08-24 23:21:08 +03:00
Egor 1048734548 Update README.md 2025-08-24 18:29:43 +03:00
Egor 62116fd5ba Update README.md 2025-08-24 17:27:52 +03:00
Egor 37a905f056 Update states.py 2025-08-24 16:58:17 +03:00
Egor bce9061401 Update users.py 2025-08-24 16:57:29 +03:00
Egor 118b282a2b Update states.py 2025-08-24 16:52:11 +03:00
Egor 6c250ed36f Update users.py 2025-08-24 16:51:31 +03:00
Egor 3842aeacca Update subscription_service.py 2025-08-24 16:23:04 +03:00
Egor 1d1afbe1da Update remnawave_service.py 2025-08-24 16:21:54 +03:00
Egor d4db55ea66 Update subscription.py 2025-08-24 16:19:32 +03:00
Egor 354964060c Update README.md 2025-08-23 18:25:49 +03:00
Egor 5bc8d8e52c Update README.md 2025-08-23 18:25:08 +03:00
Egor 8f3e8bacc4 Update README.md 2025-08-23 18:24:38 +03:00
Egor 61c94e17a4 Update promocode_service.py 2025-08-23 16:04:54 +03:00
Egor 77c52c1197 Update README.md 2025-08-23 14:38:43 +03:00
Egor c65c703e00 Update README.md 2025-08-23 12:56:42 +03:00
Egor 7560e802cb Update README.md 2025-08-23 12:53:05 +03:00
Egor a5a90a806b Update README.md 2025-08-23 12:35:37 +03:00
Egor aa0a271d14 Update README.md 2025-08-23 12:31:25 +03:00
Egor 5d4e51c862 Update README.md 2025-08-23 12:16:27 +03:00
Egor e6057bb93c Update .env.example 2025-08-23 11:58:48 +03:00
Egor 714bdd0d7c Update inline.py 2025-08-23 11:52:42 +03:00
Egor 41f3eb76c1 Update subscription.py 2025-08-23 11:51:12 +03:00
Egor e9c58b3f76 Update config.py 2025-08-23 11:48:26 +03:00
Egor 3d6976ff6e Update config.py 2025-08-23 11:44:40 +03:00
Egor ee978dff7b Add files via upload 2025-08-23 11:41:20 +03:00
Egor 0bcf5c5f43 Create 3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-08-23 11:31:06 +03:00
Egor 2ceedd6587 Update README.md 2025-08-23 07:41:34 +03:00
Egor 2ded1a4a9e Update README.md 2025-08-23 07:09:06 +03:00
79 changed files with 7017 additions and 1840 deletions
+163 -50
View File
@@ -1,87 +1,200 @@
# TELEGRAM BOT CONFIGURATION
# ===============================================
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
# ===============================================
# ===== TELEGRAM BOT =====
BOT_TOKEN=
ADMIN_IDS=
SUPPORT_USERNAME=
SUPPORT_USERNAME=@support
# DATABASE CONFIGURATION
DATABASE_URL=sqlite+aiosqlite:///./bot.db
REDIS_URL=redis://localhost:6379/0
# ===== 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
REDIS_URL=redis://redis:6379/0
# Пароли для Docker (PostgreSQL/Redis)
POSTGRES_DB=remnawave_bot
POSTGRES_USER=remnawave_user
POSTGRES_PASSWORD=secure_password_123
# ===== REMNAWAVE API =====
REMNAWAVE_API_URL=
REMNAWAVE_API_KEY=
# TRIAL SUBSCRIPTION SETTINGS
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=2
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
DEFAULT_TRAFFIC_LIMIT_GB=100
DEFAULT_DEVICE_LIMIT=1
# SUBSCRIPTION PRICING (в копейках для точности)
BASE_SUBSCRIPTION_PRICE=50000
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
# "selectable" - пользователи выбирают пакеты трафика (по умолчанию)
# "fixed" - фиксированный лимит трафика для всех подписок
TRAFFIC_SELECTION_MODE=selectable
PRICE_14_DAYS=5000
PRICE_30_DAYS=9900
PRICE_60_DAYS=18900
PRICE_90_DAYS=26900
PRICE_180_DAYS=49900
PRICE_360_DAYS=89900
# Фиксированный лимит трафика в ГБ (используется только в режиме "fixed")
# 0 = безлимит
FIXED_TRAFFIC_LIMIT_GB=100
PRICE_TRAFFIC_5GB=2000
PRICE_TRAFFIC_10GB=4000
PRICE_TRAFFIC_25GB=6000
PRICE_TRAFFIC_50GB=10000
PRICE_TRAFFIC_100GB=15000
PRICE_TRAFFIC_250GB=20000
PRICE_TRAFFIC_UNLIMITED=25000
# ===== ПЕРИОДЫ ПОДПИСКИ =====
# Доступные периоды подписки (через запятую)
# Возможные значения: 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
# Цены за трафик
PRICE_TRAFFIC_5GB=1000
PRICE_TRAFFIC_10GB=2000
PRICE_TRAFFIC_25GB=3000
PRICE_TRAFFIC_50GB=4000
PRICE_TRAFFIC_100GB=6000
PRICE_TRAFFIC_250GB=10000
PRICE_TRAFFIC_UNLIMITED=15000
# Цена за дополнительное устройство
PRICE_PER_DEVICE=5000
# REFERRAL SYSTEM SETTINGS
REFERRAL_REGISTRATION_REWARD=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
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
YOOKASSA_ENABLED=false
YOOKASSA_SHOP_ID=
YOOKASSA_SECRET_KEY=
YOOKASSA_RETURN_URL=
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
# 1 - НДС не облагается
# 2 - НДС 0%
# 3 - НДС 10%
# 4 - НДС 20%
# 5 - НДС 10/110
# 6 - НДС 20/120
YOOKASSA_PAYMENT_MODE=full_payment
# Способы расчета:
# full_payment - полная оплата
# partial_payment - частичная оплата
# advance - аванс
# full_prepayment - полная предоплата
# partial_prepayment - частичная предоплата
# credit - передача в кредит
# credit_payment - оплата кредита
YOOKASSA_PAYMENT_SUBJECT=service
# Предметы расчета:
# commodity - товар
# excise - подакцизный товар
# job - работа
# service - услуга
# gambling_bet - ставка в азартной игре
# gambling_prize - выигрыш в азартной игре
# lottery - лотерейный билет
# lottery_prize - выигрыш в лотерее
# intellectual_activity - результат интеллектуальной деятельности
# payment - платеж
# agent_commission - агентское вознаграждение
# composite - составной предмет расчета
# another - другое
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
PAYMENT_SERVICE_NAME=Интернет-сервис
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
# ===== ИНТЕРФЕЙС И UX =====
# Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
CONNECT_BUTTON_MODE=miniapp_subscription
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
# MINIAPP_CUSTOM_URL=
MINIAPP_CUSTOM_URL=
# AUTO-PAYMENT SETTINGS
AUTOPAY_WARNING_DAYS=3,1
# MONITORING SETTINGS
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
MONITORING_INTERVAL=60
INACTIVE_USER_DELETE_MONTHS=3
# Уведомления
TRIAL_WARNING_HOURS=2
ENABLE_NOTIFICATIONS=true
NOTIFICATION_RETRY_ATTEMPTS=3
MONITORING_LOGS_RETENTION_DAYS=30
NOTIFICATION_CACHE_HOURS=24
# PAYMENT SYSTEMS
TELEGRAM_STARS_ENABLED=true
TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
TRIBUTE_DONATE_LINK=https://t.me/tribute/app?startapp=XXXX
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_PORT=8081
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
WEBHOOK_URL=https://example.com
# ===== ЛОКАЛИЗАЦИЯ =====
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# ===== РАЗРАБОТКА =====
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
# LOCALIZATION
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru
# LOGGING
LOG_LEVEL=INFO
LOG_FILE=/tmp/bot.log
# DEVELOPMENT
DEBUG=false
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600
+30 -2
View File
@@ -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
+19 -9
View File
@@ -1,33 +1,43 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
wget \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Create non-root user with specific UID/GID for consistency
RUN groupadd -g 1000 app && \
useradd -u 1000 -g 1000 -m -s /bin/bash app
# Copy application code
COPY . .
# Create logs directory
RUN mkdir -p logs
# Create necessary directories and set permissions
RUN mkdir -p logs data && \
chown -R app:app /app && \
chmod -R 755 /app && \
chmod -R 777 logs
# Switch to non-root user
USER app
# Set environment variables
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
# Create non-root user
RUN useradd --create-home --shell /bin/bash app
RUN chown -R app:app /app
USER app
# Expose webhook ports
EXPOSE 8081
EXPOSE 8082
# Expose port (if needed for webhooks)
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
# Run the application
CMD ["python", "main.py"]
+470 -991
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+39 -10
View File
@@ -8,6 +8,9 @@ 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 (
@@ -19,7 +22,8 @@ from app.handlers.admin import (
promocodes as admin_promocodes, messages as admin_messages,
monitoring as admin_monitoring, referrals as admin_referrals,
rules as admin_rules, remnawave as admin_remnawave,
statistics as admin_statistics, servers as admin_servers
statistics as admin_statistics, servers as admin_servers,
maintenance as admin_maintenance
)
logger = logging.getLogger(__name__)
@@ -36,9 +40,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
try:
await cache.connect()
logger.info("Кеш инициализирован")
logger.info("Кеш инициализирован")
except Exception as e:
logger.warning(f"⚠️ Кеш не инициализирован: {e}")
logger.warning(f"Кеш не инициализирован: {e}")
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -48,25 +52,30 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
maintenance_service.set_bot(bot)
logger.info("Бот установлен в maintenance_service")
try:
redis_client = redis.from_url(settings.REDIS_URL)
await redis_client.ping()
storage = RedisStorage(redis_client)
logger.info("Подключено к Redis для FSM storage")
logger.info("Подключено к Redis для FSM storage")
except Exception as e:
logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}")
logger.info("🔄 Используется MemoryStorage для FSM")
logger.warning(f"Не удалось подключиться к Redis: {e}")
logger.info("Используется MemoryStorage для FSM")
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.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)
@@ -74,7 +83,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
promocode.register_handlers(dp)
referral.register_handlers(dp)
support.register_handlers(dp)
admin_main.register_handlers(dp)
admin_users.register_handlers(dp)
admin_subscriptions.register_handlers(dp)
@@ -86,9 +94,30 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_maintenance.register_handlers(dp)
common.register_handlers(dp)
logger.info("✅ Бот успешно настроен")
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}")
+123 -4
View File
@@ -20,6 +20,8 @@ class Settings(BaseSettings):
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"
@@ -31,7 +33,9 @@ class Settings(BaseSettings):
NOTIFICATION_CACHE_HOURS: int = 24
BASE_SUBSCRIPTION_PRICE: int = 50000
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
AVAILABLE_RENEWAL_PERIODS: str = "30,90,180"
PRICE_14_DAYS: int = 50000
PRICE_30_DAYS: int = 99000
PRICE_60_DAYS: int = 189000
@@ -49,6 +53,9 @@ class Settings(BaseSettings):
PRICE_PER_DEVICE: int = 5000
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
REFERRAL_REGISTRATION_REWARD: int = 5000
REFERRED_USER_REWARD: int = 2500
REFERRAL_COMMISSION_PERCENT: int = 10
@@ -60,6 +67,11 @@ class Settings(BaseSettings):
MONITORING_INTERVAL: int = 60
INACTIVE_USER_DELETE_MONTHS: int = 3
MAINTENANCE_MODE: bool = False
MAINTENANCE_CHECK_INTERVAL: int = 30
MAINTENANCE_AUTO_ENABLE: bool = True
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
TELEGRAM_STARS_ENABLED: bool = True
@@ -70,8 +82,26 @@ class Settings(BaseSettings):
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
TRIBUTE_WEBHOOK_PORT: int = 8081
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
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"
@@ -85,7 +115,7 @@ class Settings(BaseSettings):
APP_CONFIG_PATH: str = "app-config.json"
ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600 # 1 час
APP_CONFIG_CACHE_TTL: int = 3600
@field_validator('LOG_FILE', mode='before')
@classmethod
@@ -162,6 +192,95 @@ class Settings(BaseSettings):
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
)
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8"
+19 -5
View File
@@ -5,7 +5,7 @@ from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User
logger = logging.getLogger(__name__)
@@ -187,8 +187,13 @@ async def update_promocode(
async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
try:
from sqlalchemy import delete as sql_delete
await db.execute(
sql_delete(PromoCodeUse).where(PromoCodeUse.promocode_id == promocode.id)
)
await db.delete(promocode)
await db.commit()
@@ -222,15 +227,24 @@ async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
today_uses = today_uses_result.scalar()
recent_uses_result = await db.execute(
select(PromoCodeUse)
select(PromoCodeUse, User)
.join(User, PromoCodeUse.user_id == User.id)
.where(PromoCodeUse.promocode_id == promocode_id)
.order_by(PromoCodeUse.used_at.desc())
.limit(10)
)
recent_uses = recent_uses_result.scalars().all()
recent_uses_data = recent_uses_result.all()
recent_uses = []
for use, user in recent_uses_data:
use.user_username = user.username
use.user_full_name = user.full_name
use.user_telegram_id = user.telegram_id
recent_uses.append(use)
return {
"total_uses": total_uses,
"today_uses": today_uses,
"recent_uses": recent_uses
}
}
+59 -4
View File
@@ -19,8 +19,15 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
select(Subscription)
.options(selectinload(Subscription.user))
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
subscription = result.scalar_one_or_none()
if subscription:
subscription = await check_and_update_subscription_status(db, subscription)
return subscription
async def create_trial_subscription(
@@ -93,17 +100,30 @@ async def extend_subscription(
subscription: Subscription,
days: int
) -> Subscription:
current_time = datetime.utcnow()
subscription.extend_subscription(days)
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
if subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
if subscription.status == SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
subscription.updated_at = current_time
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f" Подписка пользователя {subscription.user_id} продлена на {days} дней")
logger.info(f" Подписка продлена до: {subscription.end_date}")
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
return subscription
@@ -510,6 +530,41 @@ async def get_subscription_renewal_cost(
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,
+81 -1
View File
@@ -275,4 +275,84 @@ async def get_revenue_by_period(
.order_by(func.date(Transaction.created_at))
)
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
async def find_tribute_transactions_by_payment_id(
db: AsyncSession,
payment_id: str,
user_telegram_id: Optional[int] = None
) -> List[Transaction]:
"""Найти все Tribute транзакции по payment_id"""
query = select(Transaction).options(selectinload(Transaction.user))
conditions = [
Transaction.external_id == f"donation_{payment_id}",
Transaction.external_id == payment_id,
Transaction.external_id.like(f"%{payment_id}%")
]
query = query.where(
and_(
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
or_(*conditions)
)
)
if user_telegram_id:
from app.database.models import User
query = query.join(User).where(User.telegram_id == user_telegram_id)
result = await db.execute(query.order_by(Transaction.created_at.desc()))
return result.scalars().all()
async def check_tribute_payment_duplicate(
db: AsyncSession,
payment_id: str,
amount_kopeks: int,
user_telegram_id: int
) -> Optional[Transaction]:
transactions = await find_tribute_transactions_by_payment_id(
db, payment_id, user_telegram_id
)
for transaction in transactions:
if (transaction.amount_kopeks == amount_kopeks and
transaction.is_completed and
transaction.user.telegram_id == user_telegram_id):
return transaction
return None
async def create_unique_tribute_transaction(
db: AsyncSession,
user_id: int,
payment_id: str,
amount_kopeks: int,
description: str
) -> Transaction:
"""Создать уникальную Tribute транзакцию с защитой от дубликатов"""
external_id = f"donation_{payment_id}"
existing = await get_transaction_by_external_id(db, external_id, PaymentMethod.TRIBUTE)
if existing:
timestamp = int(datetime.utcnow().timestamp())
external_id = f"donation_{payment_id}_{amount_kopeks}_{timestamp}"
logger.info(f"Создан уникальный external_id для избежания дубликатов: {external_id}")
return await create_transaction(
db=db,
user_id=user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description,
payment_method=PaymentMethod.TRIBUTE,
external_id=external_id,
is_completed=True
)
+76 -45
View File
@@ -126,57 +126,88 @@ async def add_user_balance(
user: User,
amount_kopeks: int,
description: str = "Пополнение баланса"
) -> User:
user.add_balance(amount_kopeks)
user.updated_at = datetime.utcnow()
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description
)
await db.commit()
await db.refresh(user)
logger.info(f"💰 Пополнен баланс пользователя {user.telegram_id}: +{amount_kopeks/100}")
return user
) -> bool:
try:
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 = "Списание с баланса"
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
if not user.subtract_balance(amount_kopeks):
if user.balance_kopeks < amount_kopeks:
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
return False
user.updated_at = datetime.utcnow()
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.WITHDRAWAL,
amount_kopeks=amount_kopeks,
description=description
)
await db.commit()
await db.refresh(user)
logger.info(f"💸 Списан баланс пользователя {user.telegram_id}: -{amount_kopeks/100}")
return True
try:
old_balance = user.balance_kopeks
user.balance_kopeks -= amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
except Exception as e:
logger.error(f" ❌ ОШИБКА СПИСАНИЯ: {e}")
await db.rollback()
return False
async def get_users_list(
@@ -324,4 +355,4 @@ async def get_users_statistics(db: AsyncSession) -> dict:
"new_today": new_today,
"new_week": new_week,
"new_month": new_month
}
}
+276
View File
@@ -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
}
+152 -17
View File
@@ -31,7 +31,7 @@ class TransactionType(Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund" # Возврат
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
@@ -44,8 +44,57 @@ class PromoCodeType(Enum):
class PaymentMethod(Enum):
TELEGRAM_STARS = "telegram_stars"
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
MANUAL = "manual"
class YooKassaPayment(Base):
__tablename__ = "yookassa_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
yookassa_payment_id = Column(String(255), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(3), default="RUB", nullable=False)
description = Column(Text, nullable=True)
status = Column(String(50), nullable=False)
is_paid = Column(Boolean, default=False)
is_captured = Column(Boolean, default=False)
confirmation_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
payment_method_type = Column(String(50), nullable=True)
refundable = Column(Boolean, default=False)
test_mode = Column(Boolean, default=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
yookassa_created_at = Column(DateTime, nullable=True)
captured_at = Column(DateTime, nullable=True)
user = relationship("User", backref="yookassa_payments")
transaction = relationship("Transaction", backref="yookassa_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == "pending"
@property
def is_succeeded(self) -> bool:
return self.status == "succeeded" and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ["canceled", "failed"]
@property
def can_be_captured(self) -> bool:
return self.status == "waiting_for_capture"
def __repr__(self):
return f"<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>"
class User(Base):
__tablename__ = "users"
@@ -55,31 +104,27 @@ class User(Base):
username = Column(String(255), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
status = Column(String(20), default=UserStatus.ACTIVE.value)
language = Column(String(5), default="ru")
balance_kopeks = Column(Integer, default=0)
used_promocodes = Column(Integer, default=0)
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
referral_code = Column(String(20), unique=True, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_activity = Column(DateTime, default=func.now())
remnawave_uuid = Column(String(255), nullable=True, unique=True)
broadcasts = relationship("BroadcastHistory", back_populates="admin")
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
subscription = relationship("Subscription", back_populates="user", uselist=False)
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
@property
def balance_rubles(self) -> float:
@@ -104,7 +149,7 @@ class Subscription(Base):
__tablename__ = "subscriptions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
is_trial = Column(Boolean, default=True)
@@ -133,21 +178,111 @@ class Subscription(Base):
@property
def is_active(self) -> bool:
current_time = datetime.utcnow()
return (
self.status == SubscriptionStatus.ACTIVE.value and
self.end_date > datetime.utcnow()
self.end_date > current_time
)
@property
def is_expired(self) -> bool:
"""Проверяет, истёк ли срок подписки"""
return self.end_date <= datetime.utcnow()
@property
def should_be_expired(self) -> bool:
current_time = datetime.utcnow()
return (
self.status == SubscriptionStatus.ACTIVE.value and
self.end_date <= current_time
)
@property
def actual_status(self) -> str:
current_time = datetime.utcnow()
if self.status == SubscriptionStatus.EXPIRED.value:
return "expired"
if self.status == SubscriptionStatus.DISABLED.value:
return "disabled"
if self.status == SubscriptionStatus.ACTIVE.value:
if self.end_date <= current_time:
return "expired"
else:
return "active"
if self.status == SubscriptionStatus.TRIAL.value:
if self.end_date <= current_time:
return "expired"
else:
return "trial"
return self.status
@property
def status_display(self) -> str:
actual_status = self.actual_status
current_time = datetime.utcnow()
if actual_status == "expired":
return "🔴 Истекла"
elif actual_status == "active":
if self.is_trial:
return "🎯 Тестовая"
else:
return "🟢 Активна"
elif actual_status == "disabled":
return "⚫ Отключена"
elif actual_status == "trial":
return "🎯 Тестовая"
return "❓ Неизвестно"
@property
def status_emoji(self) -> str:
actual_status = self.actual_status
if actual_status == "expired":
return "🔴"
elif actual_status == "active":
if self.is_trial:
return "🎁"
else:
return "💎"
elif actual_status == "disabled":
return ""
elif actual_status == "trial":
return "🎁"
return ""
@property
def days_left(self) -> int:
if self.is_expired:
current_time = datetime.utcnow()
if self.end_date <= current_time:
return 0
delta = self.end_date - datetime.utcnow()
return delta.days
delta = self.end_date - current_time
return max(0, delta.days)
@property
def time_left_display(self) -> str:
current_time = datetime.utcnow()
if self.end_date <= current_time:
return "истёк"
delta = self.end_date - current_time
days = delta.days
hours = delta.seconds // 3600
minutes = (delta.seconds % 3600) // 60
if days > 0:
return f"{days} дн."
elif hours > 0:
return f"{hours} ч."
else:
return f"{minutes} мин."
@property
def traffic_used_percent(self) -> float:
@@ -418,4 +553,4 @@ class SubscriptionServer(Base):
paid_price_kopeks = Column(Integer, default=0)
subscription = relationship("Subscription", backref="subscription_servers")
server_squad = relationship("ServerSquad", backref="subscription_servers")
server_squad = relationship("ServerSquad", backref="subscription_servers")
+375
View File
@@ -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
+27 -2
View File
@@ -33,7 +33,7 @@ class RemnaWaveUser:
username: str
status: UserStatus
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
lifetime_used_traffic_bytes: int # Новое поле
traffic_limit_bytes: int
traffic_limit_strategy: TrafficLimitStrategy
expire_at: datetime
@@ -46,6 +46,16 @@ class RemnaWaveUser:
active_internal_squads: List[Dict[str, str]]
created_at: datetime
updated_at: datetime
sub_last_user_agent: Optional[str] = None
sub_last_opened_at: Optional[datetime] = None
online_at: Optional[datetime] = None
sub_revoked_at: Optional[datetime] = None
last_traffic_reset_at: Optional[datetime] = None
trojan_password: Optional[str] = None
vless_uuid: Optional[str] = None
ss_password: Optional[str] = None
first_connected_at: Optional[datetime] = None
last_triggered_threshold: int = 0
@dataclass
@@ -498,8 +508,23 @@ class RemnaWaveAPI:
subscription_url=user_data['subscriptionUrl'],
active_internal_squads=user_data['activeInternalSquads'],
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00'))
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
sub_last_user_agent=user_data.get('subLastUserAgent'),
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
trojan_password=user_data.get('trojanPassword'),
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
if date_str:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return None
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
return RemnaWaveInternalSquad(
+77 -23
View File
@@ -28,7 +28,6 @@ class TributeService:
return None
try:
payment_url = f"{self.donate_link}&user_id={user_id}"
logger.info(f"Создана ссылка Tribute для пользователя {user_id}")
@@ -41,7 +40,7 @@ class TributeService:
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
if not self.webhook_secret:
logger.warning("Webhook secret не настроен")
logger.warning("Webhook secret не настроен, пропускаем проверку")
return True
try:
@@ -51,31 +50,54 @@ class TributeService:
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
is_valid = hmac.compare_digest(signature, expected_signature)
if is_valid:
logger.info("✅ Подпись Tribute webhook проверена успешно")
else:
logger.error("❌ Неверная подпись Tribute webhook")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи webhook: {e}")
return False
async def process_webhook(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
async def process_webhook(self, payload_or_data) -> Optional[Dict[str, Any]]:
try:
logger.info(f"🔄 Начинаем обработку Tribute webhook")
payment_id = payload.get("id") or payload.get("payment_id")
status = payload.get("status")
amount_rubles = payload.get("amount", 0)
telegram_user_id = payload.get("telegram_user_id") or payload.get("user_id")
if isinstance(payload_or_data, str):
try:
webhook_data = json.loads(payload_or_data)
logger.info(f"📊 Распарсенные данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON: {e}")
return None
else:
webhook_data = payload_or_data
if not payment_id and "payload" in payload:
data = payload["payload"]
payment_id = None
status = None
amount_kopeks = 0
telegram_user_id = None
payment_id = webhook_data.get("id") or webhook_data.get("payment_id")
status = webhook_data.get("status")
amount_kopeks = webhook_data.get("amount", 0)
telegram_user_id = webhook_data.get("telegram_user_id") or webhook_data.get("user_id")
if not payment_id and "payload" in webhook_data:
data = webhook_data["payload"]
payment_id = data.get("id") or data.get("payment_id")
status = data.get("status")
amount_rubles = data.get("amount", 0)
amount_kopeks = data.get("amount", 0)
telegram_user_id = data.get("telegram_user_id") or data.get("user_id")
if not payment_id and "name" in payload:
event_name = payload.get("name")
data = payload.get("payload", {})
if not payment_id and "name" in webhook_data:
event_name = webhook_data.get("name")
data = webhook_data.get("payload", {})
payment_id = str(data.get("donation_request_id"))
amount_kopeks = data.get("amount", 0)
telegram_user_id = data.get("telegram_user_id")
@@ -87,22 +109,54 @@ class TributeService:
else:
status = "unknown"
logger.info(f"Обработка Tribute webhook: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}")
logger.info(f"📝 Извлеченные данные: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}")
if not telegram_user_id:
logger.error("Не найден telegram_user_id в webhook данных")
logger.error("Не найден telegram_user_id в webhook данных")
logger.error(f"🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
return None
return {
try:
telegram_user_id = int(telegram_user_id)
except (ValueError, TypeError):
logger.error(f"❌ Некорректный telegram_user_id: {telegram_user_id}")
return None
result = {
"event_type": "payment",
"payment_id": payment_id or f"tribute_{telegram_user_id}_{amount_kopeks}",
"user_id": int(telegram_user_id),
"amount_kopeks": amount_kopeks,
"user_id": telegram_user_id,
"amount_kopeks": int(amount_kopeks) if amount_kopeks else 0,
"status": status or "paid",
"external_id": f"donation_{payment_id}"
"external_id": f"donation_{payment_id or 'unknown'}",
"payment_system": "tribute"
}
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
return result
except Exception as e:
logger.error(f"Ошибка обработки Tribute webhook: {e}")
logger.error(f"Webhook payload: {json.dumps(payload, ensure_ascii=False)}")
return None
logger.error(f"Ошибка обработки Tribute webhook: {e}", exc_info=True)
logger.error(f"🔍 Webhook data для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
return None
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
try:
logger.info(f"Запрос статуса платежа {payment_id}")
return {"status": "unknown", "payment_id": payment_id}
except Exception as e:
logger.error(f"Ошибка получения статуса платежа: {e}")
return None
async def refund_payment(
self,
payment_id: str,
amount_kopeks: Optional[int] = None,
reason: str = "Возврат по запросу"
) -> Optional[Dict[str, Any]]:
try:
logger.info(f"Создание возврата для платежа {payment_id}")
return {"refund_id": f"refund_{payment_id}", "status": "pending"}
except Exception as e:
logger.error(f"Ошибка создания возврата: {e}")
return None
+69 -13
View File
@@ -1,4 +1,5 @@
import logging
import json
from typing import Optional
from aiohttp import web
@@ -26,9 +27,11 @@ class WebhookServer:
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
self.app.router.add_get('/health', self._health_check)
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: {settings.TRIBUTE_WEBHOOK_PATH}")
logger.info(f" - Health check: /health")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
return self.app
@@ -49,11 +52,11 @@ class WebhookServer:
await self.site.start()
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
logger.info(f"🎯 Tribute webhook URL: http://your-server:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
logger.info(f"Tribute webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
logger.info(f"🎯 Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
except Exception as e:
logger.error(f"❌ Ошибка запуска webhook сервера: {e}")
logger.error(f"❌ Ошибка запуска Tribute webhook сервера: {e}")
raise
async def stop(self):
@@ -61,31 +64,82 @@ class WebhookServer:
try:
if self.site:
await self.site.stop()
logger.info("Webhook сайт остановлен")
logger.info("Tribute webhook сайт остановлен")
if self.runner:
await self.runner.cleanup()
logger.info("Webhook runner очищен")
logger.info("Tribute webhook runner очищен")
except Exception as e:
logger.error(f"Ошибка остановки webhook сервера: {e}")
logger.error(f"Ошибка остановки Tribute webhook сервера: {e}")
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Tribute-Signature',
}
)
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен Tribute webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("⚠️ Получен пустой webhook от Tribute")
return web.json_response(
{"status": "error", "reason": "empty_body"},
status=400
)
payload = raw_body.decode('utf-8')
logger.info(f"📄 Payload: {payload}")
try:
webhook_data = json.loads(payload)
logger.info(f"📊 Распарсенные данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON: {e}")
return web.json_response(
{"status": "error", "reason": "invalid_json"},
status=400
)
signature = request.headers.get('X-Tribute-Signature')
logger.info(f"🔐 Signature: {signature}")
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
from app.external.tribute import TributeService as TributeAPI
tribute_api = TributeAPI()
if not tribute_api.verify_webhook_signature(payload, signature):
logger.error("❌ Неверная подпись Tribute webhook")
return web.json_response(
{"status": "error", "reason": "invalid_signature"},
status=401
)
result = await self.tribute_service.process_webhook(payload, signature)
return web.json_response(result, status=200)
if result:
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
return web.json_response({"status": "ok", "result": result}, status=200)
else:
logger.error("❌ Ошибка обработки Tribute webhook")
return web.json_response(
{"status": "error", "reason": "processing_failed"},
status=400
)
except Exception as e:
logger.error(f"Ошибка обработки Tribute webhook: {e}")
logger.error(f"❌ Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
return web.json_response(
{"status": "error", "reason": "internal_error"},
{"status": "error", "reason": "internal_error", "message": str(e)},
status=500
)
@@ -94,5 +148,7 @@ class WebhookServer:
return web.json_response({
"status": "ok",
"service": "tribute-webhooks",
"tribute_enabled": settings.TRIBUTE_ENABLED
})
"tribute_enabled": settings.TRIBUTE_ENABLED,
"port": settings.TRIBUTE_WEBHOOK_PORT,
"path": settings.TRIBUTE_WEBHOOK_PATH
})
+243
View File
@@ -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
+235
View File
@@ -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
)
+20 -11
View File
@@ -45,7 +45,8 @@ async def show_messages_menu(
await callback.message.edit_text(
text,
reply_markup=get_admin_messages_keyboard(db_user.language)
reply_markup=get_admin_messages_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@@ -60,7 +61,8 @@ async def show_broadcast_targets(
await callback.message.edit_text(
"🎯 <b>Выбор целевой аудитории</b>\n\n"
"Выберите категорию пользователей для рассылки:",
reply_markup=get_broadcast_target_keyboard(db_user.language)
reply_markup=get_broadcast_target_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@@ -118,7 +120,8 @@ async def show_messages_history(
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@@ -135,7 +138,7 @@ async def show_custom_broadcast(
stats = await get_users_statistics(db)
text = f"""
🔍 <b>Рассылка по критериям</b>
📝 <b>Рассылка по критериям</b>
📊 <b>Доступные фильтры:</b>
@@ -158,7 +161,8 @@ async def show_custom_broadcast(
await callback.message.edit_text(
text,
reply_markup=get_custom_criteria_keyboard(db_user.language)
reply_markup=get_custom_criteria_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@@ -196,7 +200,8 @@ async def select_custom_criteria(
f"<i>Поддерживается HTML разметка</i>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
])
]),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_broadcast_message)
@@ -233,7 +238,8 @@ async def select_broadcast_target(
f"<i>Поддерживается HTML разметка</i>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
])
]),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_broadcast_message)
@@ -284,7 +290,8 @@ async def process_broadcast_message(
await message.answer(
preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.set_state(AdminStates.confirming_broadcast)
@@ -304,7 +311,8 @@ async def confirm_broadcast(
await callback.message.edit_text(
"📨 Начинаю рассылку...\n\n"
"⏳ Это может занять несколько минут.",
reply_markup=None
reply_markup=None,
parse_mode="HTML"
)
if target.startswith('custom_'):
@@ -367,7 +375,8 @@ async def confirm_broadcast(
result_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
])
]),
parse_mode="HTML"
)
await state.clear()
@@ -548,4 +557,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
+547 -110
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import AdminStates
from app.database.models import User, PromoCodeType
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User
from app.keyboards.admin import (
get_admin_promocodes_keyboard, get_promocode_type_keyboard,
get_admin_pagination_keyboard, get_confirmation_keyboard
@@ -77,6 +77,7 @@ async def show_promocodes_list(
return
text = f"🎫 <b>Список промокодов</b> (стр. {page}/{total_pages})\n\n"
keyboard = []
for promo in promocodes:
status_emoji = "" if promo.is_active else ""
@@ -93,9 +94,14 @@ async def show_promocodes_list(
if promo.valid_until:
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
text += f"🔧 Управление: /promo_{promo.id}\n\n"
keyboard = []
keyboard.append([
types.InlineKeyboardButton(
text=f"🎫 {promo.code}",
callback_data=f"promo_manage_{promo.id}"
)
])
text += "\n"
if total_pages > 1:
pagination_row = get_admin_pagination_keyboard(
@@ -182,128 +188,75 @@ async def show_promocode_management(
)
await callback.answer()
@admin_required
@error_handler
async def toggle_promocode_status(
async def show_promocode_edit_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
try:
promo_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
new_status = not promo.is_active
await update_promocode(db, promo, is_active=new_status)
status_text = "активирован" if new_status else "деактивирован"
await callback.answer(f"✅ Промокод {status_text}", show_alert=True)
await show_promocode_management(callback, db_user, db)
@admin_required
@error_handler
async def confirm_delete_promocode(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
text = f"""
<b>Подтверждение удаления</b>
<b>Редактирование промокода</b> <code>{promo.code}</code>
Вы действительно хотите удалить промокод <code>{promo.code}</code>?
<b>Внимание:</b> Это действие нельзя отменить!
💰 <b>Текущие параметры:</b>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
if promo.type == PromoCodeType.BALANCE.value:
text += f"• Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type in [PromoCodeType.SUBSCRIPTION_DAYS.value, PromoCodeType.TRIAL_SUBSCRIPTION.value]:
text += f"• Дней: {promo.subscription_days}\n"
text += f"• Использований: {promo.current_uses}/{promo.max_uses}\n"
if promo.valid_until:
text += f"• До: {format_datetime(promo.valid_until)}\n"
else:
text += f"• Срок: бессрочно\n"
text += f"\nВыберите параметр для изменения:"
keyboard = [
[
types.InlineKeyboardButton(
text=" Да, удалить",
callback_data=f"promo_delete_confirm_{promo.id}"
),
text="📅 Дата окончания",
callback_data=f"promo_edit_date_{promo.id}"
)
],
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"promo_manage_{promo.id}"
text="📊 Количество использований",
callback_data=f"promo_edit_uses_{promo.id}"
)
]
])
]
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def delete_promocode_confirmed(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
if promo.type == PromoCodeType.BALANCE.value:
keyboard.insert(1, [
types.InlineKeyboardButton(
text="💰 Сумма бонуса",
callback_data=f"promo_edit_amount_{promo.id}"
)
])
elif promo.type in [PromoCodeType.SUBSCRIPTION_DAYS.value, PromoCodeType.TRIAL_SUBSCRIPTION.value]:
keyboard.insert(1, [
types.InlineKeyboardButton(
text="📅 Количество дней",
callback_data=f"promo_edit_days_{promo.id}"
)
])
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
code = promo.code
success = await delete_promocode(db, promo)
if success:
await callback.answer(f"✅ Промокод {code} удален", show_alert=True)
await show_promocodes_list(callback, db_user, db)
else:
await callback.answer("❌ Ошибка удаления промокода", show_alert=True)
@admin_required
@error_handler
async def show_promocode_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
stats = await get_promocode_statistics(db, promo_id)
text = f"""
📊 <b>Статистика промокода</b> <code>{promo.code}</code>
📈 <b>Общая статистика:</b>
- Всего использований: {stats['total_uses']}
- Использований сегодня: {stats['today_uses']}
- Осталось использований: {promo.max_uses - promo.current_uses}
📅 <b>Последние использования:</b>
"""
for use in stats['recent_uses'][:5]:
use_date = format_datetime(use.used_at)
text += f"- {use_date} (ID: {use.user_id})\n"
if not stats['recent_uses']:
text += "- Пока не было использований\n"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
keyboard.extend([
[
types.InlineKeyboardButton(
text="⬅️ Назад",
@@ -312,7 +265,160 @@ async def show_promocode_stats(
]
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def start_edit_promocode_date(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
try:
promo_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
await state.update_data(
editing_promo_id=promo_id,
edit_action="date"
)
text = f"""
📅 <b>Изменение даты окончания промокода</b>
Введите количество дней до окончания (от текущего момента):
Введите <b>0</b> для бессрочного промокода
Введите положительное число для установки срока
<i>Например: 30 (промокод будет действовать 30 дней)</i>
ID промокода: {promo_id}
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await state.set_state(AdminStates.setting_promocode_expiry)
await callback.answer()
@admin_required
@error_handler
async def start_edit_promocode_amount(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
try:
promo_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
await state.update_data(
editing_promo_id=promo_id,
edit_action="amount"
)
text = f"""
💰 <b>Изменение суммы бонуса промокода</b>
Введите новую сумму в рублях:
<i>Например: 500</i>
ID промокода: {promo_id}
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await state.set_state(AdminStates.setting_promocode_value)
await callback.answer()
@admin_required
@error_handler
async def start_edit_promocode_days(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
# ИСПРАВЛЕНИЕ: берем последний элемент как ID
try:
promo_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
await state.update_data(
editing_promo_id=promo_id,
edit_action="days"
)
text = f"""
📅 <b>Изменение количества дней подписки</b>
Введите новое количество дней:
<i>Например: 30</i>
ID промокода: {promo_id}
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await state.set_state(AdminStates.setting_promocode_value)
await callback.answer()
@admin_required
@error_handler
async def start_edit_promocode_uses(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
try:
promo_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
await state.update_data(
editing_promo_id=promo_id,
edit_action="uses"
)
text = f"""
📊 <b>Изменение максимального количества использований</b>
Введите новое количество использований:
Введите <b>0</b> для безлимитных использований
Введите положительное число для ограничения
<i>Например: 100</i>
ID промокода: {promo_id}
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await state.set_state(AdminStates.setting_promocode_uses)
await callback.answer()
@@ -375,7 +481,6 @@ async def process_promocode_code(
await message.answer("❌ Код должен содержать только латинские буквы и цифры (3-20 символов)")
return
from app.database.crud.promocode import get_promocode_by_code
existing = await get_promocode_by_code(db, code)
if existing:
await message.answer("❌ Промокод с таким кодом уже существует")
@@ -411,12 +516,18 @@ async def process_promocode_code(
async def process_promocode_value(
message: types.Message,
db_user: User,
state: FSMContext
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
if data.get('editing_promo_id'):
await handle_edit_value(message, db_user, state, db)
return
try:
value = int(message.text.strip())
data = await state.get_data()
promo_type = data.get('promocode_type')
if promo_type == "balance" and (value < 1 or value > 10000):
@@ -437,13 +548,72 @@ async def process_promocode_value(
await message.answer("❌ Введите корректное число")
async def handle_edit_value(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
edit_action = data.get('edit_action')
promo = await db.get(PromoCode, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
return
try:
value = int(message.text.strip())
if edit_action == "amount":
if value < 1 or value > 10000:
await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей")
return
await update_promocode(db, promo, balance_bonus_kopeks=value * 100)
await message.answer(
f"✅ Сумма бонуса изменена на {value}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
])
)
elif edit_action == "days":
if value < 1 or value > 3650:
await message.answer("❌ Количество дней должно быть от 1 до 3650")
return
await update_promocode(db, promo, subscription_days=value)
await message.answer(
f"✅ Количество дней изменено на {value}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
])
)
await state.clear()
logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: {edit_action} = {value}")
except ValueError:
await message.answer("❌ Введите корректное число")
@admin_required
@error_handler
async def process_promocode_uses(
message: types.Message,
db_user: User,
state: FSMContext
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
if data.get('editing_promo_id'):
await handle_edit_uses(message, db_user, state, db)
return
try:
max_uses = int(message.text.strip())
@@ -465,6 +635,54 @@ async def process_promocode_uses(
await message.answer("❌ Введите корректное число")
async def handle_edit_uses(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
return
try:
max_uses = int(message.text.strip())
if max_uses < 0 or max_uses > 100000:
await message.answer("❌ Количество использований должно быть от 0 до 100,000")
return
if max_uses == 0:
max_uses = 999999
if max_uses < promo.current_uses:
await message.answer(
f"❌ Новый лимит ({max_uses}) не может быть меньше текущих использований ({promo.current_uses})"
)
return
await update_promocode(db, promo, max_uses=max_uses)
uses_text = "безлимитное" if max_uses == 999999 else str(max_uses)
await message.answer(
f"✅ Максимальное количество использований изменено на {uses_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
])
)
await state.clear()
logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: max_uses = {max_uses}")
except ValueError:
await message.answer("❌ Введите корректное число")
@admin_required
@error_handler
async def process_promocode_expiry(
@@ -473,6 +691,12 @@ async def process_promocode_expiry(
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
if data.get('editing_promo_id'):
await handle_edit_expiry(message, db_user, state, db)
return
try:
expiry_days = int(message.text.strip())
@@ -480,7 +704,6 @@ async def process_promocode_expiry(
await message.answer("❌ Срок действия должен быть от 0 до 3650 дней")
return
data = await state.get_data()
code = data.get('promocode_code')
promo_type = data.get('promocode_type')
value = data.get('promocode_value', 0)
@@ -544,6 +767,209 @@ async def process_promocode_expiry(
await message.answer("❌ Введите корректное число дней")
async def handle_edit_expiry(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
return
try:
expiry_days = int(message.text.strip())
if expiry_days < 0 or expiry_days > 3650:
await message.answer("❌ Срок действия должен быть от 0 до 3650 дней")
return
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
await update_promocode(db, promo, valid_until=valid_until)
if valid_until:
expiry_text = f"до {format_datetime(valid_until)}"
else:
expiry_text = "бессрочно"
await message.answer(
f"✅ Срок действия промокода изменен: {expiry_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
])
)
await state.clear()
logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: expiry = {expiry_days} дней")
except ValueError:
await message.answer("❌ Введите корректное число дней")
@admin_required
@error_handler
async def toggle_promocode_status(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
new_status = not promo.is_active
await update_promocode(db, promo, is_active=new_status)
status_text = "активирован" if new_status else "деактивирован"
await callback.answer(f"✅ Промокод {status_text}", show_alert=True)
await show_promocode_management(callback, db_user, db)
@admin_required
@error_handler
async def confirm_delete_promocode(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
promo_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
text = f"""
<b>Подтверждение удаления</b>
Вы действительно хотите удалить промокод <code>{promo.code}</code>?
📊 <b>Информация о промокоде:</b>
Использований: {promo.current_uses}/{promo.max_uses}
Статус: {'Активен' if promo.is_active else 'Неактивен'}
<b> Внимание:</b> Это действие нельзя отменить!
ID: {promo_id}
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="✅ Да, удалить",
callback_data=f"promo_delete_confirm_{promo.id}"
),
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"promo_manage_{promo.id}"
)
]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def delete_promocode_confirmed(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
promo_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
code = promo.code
success = await delete_promocode(db, promo)
if success:
await callback.answer(f"✅ Промокод {code} удален", show_alert=True)
await show_promocodes_list(callback, db_user, db)
else:
await callback.answer("❌ Ошибка удаления промокода", show_alert=True)
@admin_required
@error_handler
async def show_promocode_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
stats = await get_promocode_statistics(db, promo_id)
text = f"""
📊 <b>Статистика промокода</b> <code>{promo.code}</code>
📈 <b>Общая статистика:</b>
- Всего использований: {stats['total_uses']}
- Использований сегодня: {stats['today_uses']}
- Осталось использований: {promo.max_uses - promo.current_uses}
📅 <b>Последние использования:</b>
"""
if stats['recent_uses']:
for use in stats['recent_uses'][:5]:
use_date = format_datetime(use.used_at)
if hasattr(use, 'user_username') and use.user_username:
user_display = f"@{use.user_username}"
elif hasattr(use, 'user_full_name') and use.user_full_name:
user_display = use.user_full_name
elif hasattr(use, 'user_telegram_id'):
user_display = f"ID{use.user_telegram_id}"
else:
user_display = f"ID{use.user_id}"
text += f"- {use_date} | {user_display}\n"
else:
text += "- Пока не было использований\n"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"promo_manage_{promo.id}"
)
]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes")
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
@@ -552,11 +978,22 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_"))
dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_"))
dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_"))
dp.callback_query.register(start_edit_promocode_date, F.data.startswith("promo_edit_date_"))
dp.callback_query.register(start_edit_promocode_amount, F.data.startswith("promo_edit_amount_"))
dp.callback_query.register(start_edit_promocode_days, F.data.startswith("promo_edit_days_"))
dp.callback_query.register(start_edit_promocode_uses, F.data.startswith("promo_edit_uses_"))
dp.callback_query.register(
show_promocode_edit_menu,
F.data.regexp(r"^promo_edit_\d+$")
)
dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_"))
dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_"))
dp.message.register(process_promocode_code, AdminStates.creating_promocode)
dp.message.register(process_promocode_value, AdminStates.setting_promocode_value)
dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses)
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
+228 -53
View File
@@ -1315,51 +1315,135 @@ async def restart_all_nodes(
@admin_required
@error_handler
async def show_sync_options(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = """
text = """
🔄 <b>Синхронизация с RemnaWave</b>
Выберите тип синхронизации:
🔄 <b>Синхронизировать всех</b>
Полная синхронизация всех пользователей
Создание новых пользователей из панели
Обновление данных существующих
Удаление неактуальных подписок
🔄 <b>Полная синхронизация выполняет:</b>
Создание новых пользователей из панели в боте
Обновление данных существующих пользователей
Деактивация подписок пользователей, отсутствующих в панели
Сохранение балансов пользователей
Время выполнения: 2-5 минут
🆕 <b>Только новых</b>
Создание пользователей из панели, которых нет в боте
Быстрое добавление при массовой регистрации
Время выполнения: 30 секунд - 2 минуты
📈 <b>Обновить данные</b>
Обновление информации о трафике и подписках
Синхронизация статуса и лимитов
Обновление подключенных сквадов
Время выполнения: 1-3 минуты
<b>Важно:</b>
Во время синхронизации не выполняйте другие операции
При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы
Рекомендуется делать полную синхронизацию ежедневно
Баланс пользователей НЕ удаляется
"""
keyboard = [
[types.InlineKeyboardButton(text="🔄 Синхронизировать всех", callback_data="sync_all_users")],
[types.InlineKeyboardButton(text="🆕 Только новых", callback_data="sync_new_users")],
[types.InlineKeyboardButton(text="📈 Обновить данные", callback_data="sync_update_data")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
keyboard = [
[types.InlineKeyboardButton(text="🔄 Запустить полную синхронизацию", callback_data="sync_all_users")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def sync_all_users(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Выполняет полную синхронизацию всех пользователей"""
progress_text = """
🔄 <b>Выполняется полная синхронизация...</b>
📋 Этапы:
Загрузка ВСЕХ пользователей из панели RemnaWave
Создание новых пользователей в боте
Обновление существующих пользователей
Деактивация подписок отсутствующих пользователей
Сохранение балансов
Пожалуйста, подождите...
"""
await callback.message.edit_text(progress_text, reply_markup=None)
remnawave_service = RemnaWaveService()
stats = await remnawave_service.sync_users_from_panel(db, "all")
total_operations = stats['created'] + stats['updated'] + stats.get('deleted', 0)
if stats['errors'] == 0:
status_emoji = ""
status_text = "успешно завершена"
elif stats['errors'] < total_operations:
status_emoji = "⚠️"
status_text = "завершена с предупреждениями"
else:
status_emoji = ""
status_text = "завершена с ошибками"
text = f"""
{status_emoji} <b>Полная синхронизация {status_text}</b>
📊 <b>Результат:</b>
🆕 Создано: {stats['created']}
🔄 Обновлено: {stats['updated']}
🗑 Деактивировано: {stats.get('deleted', 0)}
Ошибок: {stats['errors']}
"""
if stats.get('deleted', 0) > 0:
text += f"""
🗑 <b>Деактивированные подписки:</b>
Деактивированы подписки пользователей, которые
отсутствуют в панели RemnaWave.
💰 Балансы пользователей сохранены.
"""
if stats['errors'] > 0:
text += f"""
<b>Внимание:</b>
Некоторые операции завершились с ошибками.
Проверьте логи для получения подробной информации.
"""
text += f"""
💡 <b>Рекомендации:</b>
Полная синхронизация выполнена
Рекомендуется запускать раз в день
Все пользователи из панели синхронизированы
"""
keyboard = []
if stats['errors'] > 0:
keyboard.append([
types.InlineKeyboardButton(
text="🔄 Повторить синхронизацию",
callback_data="sync_all_users"
)
])
keyboard.extend([
[
types.InlineKeyboardButton(text="📊 Статистика системы", callback_data="admin_rw_system"),
types.InlineKeyboardButton(text="🌐 Ноды", callback_data="admin_rw_nodes")
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
@@ -1442,7 +1526,6 @@ async def validate_subscriptions(
remnawave_service = RemnaWaveService()
stats = await remnawave_service.validate_and_fix_subscriptions(db)
# Формируем отчет
if stats['errors'] == 0:
status_emoji = ""
status_text = "успешно завершена"
@@ -1498,7 +1581,6 @@ async def cleanup_subscriptions(
remnawave_service = RemnaWaveService()
stats = await remnawave_service.cleanup_orphaned_subscriptions(db)
# Формируем отчет
if stats['errors'] == 0:
status_emoji = ""
status_text = "успешно завершена"
@@ -1537,6 +1619,114 @@ async def cleanup_subscriptions(
)
await callback.answer()
@admin_required
@error_handler
async def force_cleanup_all_orphaned_users(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"🗑️ Выполняется принудительная очистка всех пользователей, отсутствующих в панели...\n\n"
"⚠️ ВНИМАНИЕ: Это полностью удалит ВСЕ данные пользователей!\n"
"📊 Включая: транзакции, реферальные доходы, промокоды, серверы, балансы\n\n"
"⏳ Пожалуйста, подождите...",
reply_markup=None
)
remnawave_service = RemnaWaveService()
stats = await remnawave_service.cleanup_orphaned_subscriptions(db)
if stats['errors'] == 0:
status_emoji = ""
status_text = "успешно завершена"
else:
status_emoji = "⚠️"
status_text = "завершена с ошибками"
text = f"""
{status_emoji} <b>Принудительная очистка {status_text}</b>
📊 <b>Результаты:</b>
🔍 Проверено подписок: {stats['checked']}
🗑 Полностью очищено: {stats['deactivated']}
Ошибок: {stats['errors']}
"""
if stats['deactivated'] > 0:
text += f"""
🗑 <b>Полностью очищенные данные:</b>
Подписки сброшены к начальному состоянию
Удалены ВСЕ транзакции пользователей
Удалены ВСЕ реферальные доходы
Удалены использования промокодов
Сброшены балансы к нулю
Удалены подключенные серверы
Сброшены HWID устройства в RemnaWave
Очищены RemnaWave UUID
"""
else:
text += f"\n✅ Неактуальных подписок не найдено!\nВсе пользователи синхронизированы с панелью."
if stats['errors'] > 0:
text += f"\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации."
keyboard = [
[types.InlineKeyboardButton(text="🔄 Повторить очистку", callback_data="force_cleanup_orphaned")],
[types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
[types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def confirm_force_cleanup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = """
<b>ВНИМАНИЕ! ОПАСНАЯ ОПЕРАЦИЯ!</b>
🗑 <b>Принудительная очистка полностью удалит:</b>
ВСЕ транзакции пользователей отсутствующих в панели
ВСЕ реферальные доходы и связи
ВСЕ использования промокодов
ВСЕ подключенные серверы подписок
ВСЕ балансы (сброс к нулю)
ВСЕ HWID устройства в RemnaWave
ВСЕ RemnaWave UUID и ссылки
<b>Это действие НЕОБРАТИМО!</b>
Используйте только если:
Обычная синхронизация не помогает
Нужно полностью очистить "мусорные" данные
После массового удаления пользователей из панели
<b>Вы действительно хотите продолжить?</b>
"""
keyboard = [
[types.InlineKeyboardButton(text="🗑️ ДА, ОЧИСТИТЬ ВСЕ", callback_data="force_cleanup_orphaned")],
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rw_sync")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
@@ -1721,37 +1911,22 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(manage_node, F.data.startswith("node_restart_"))
dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes")
dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync")
dp.callback_query.register(sync_users, F.data.startswith("sync_"))
dp.callback_query.register(show_sync_recommendations, F.data == "sync_recommendations")
dp.callback_query.register(validate_subscriptions, F.data == "sync_validate")
dp.callback_query.register(cleanup_subscriptions, F.data == "sync_cleanup")
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
dp.callback_query.register(show_squads_management, F.data == "admin_rw_squads")
dp.callback_query.register(show_squad_details, F.data.startswith("admin_squad_manage_"))
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_add_users_"))
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_remove_users_"))
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_delete_"))
dp.callback_query.register(show_squad_edit_menu, F.data.startswith("squad_edit_") & ~F.data.startswith("squad_edit_inbounds_"))
dp.callback_query.register(show_squad_inbounds_selection, F.data.startswith("squad_edit_inbounds_"))
dp.callback_query.register(show_squad_rename_form, F.data.startswith("squad_rename_"))
dp.callback_query.register(cancel_squad_rename, F.data.startswith("cancel_rename_"))
dp.callback_query.register(toggle_squad_inbound, F.data.startswith("sqd_tgl_"))
dp.callback_query.register(save_squad_inbounds, F.data.startswith("sqd_save_"))
dp.callback_query.register(show_squad_edit_menu_short, F.data.startswith("sqd_edit_"))
dp.callback_query.register(start_squad_creation, F.data == "admin_squad_create")
dp.callback_query.register(cancel_squad_creation, F.data == "cancel_squad_create")
dp.callback_query.register(toggle_create_inbound, F.data.startswith("create_tgl_"))
dp.callback_query.register(finish_squad_creation, F.data == "create_squad_finish")
dp.message.register(
+9 -10
View File
@@ -306,13 +306,13 @@ async def show_pricing_settings(
"""
keyboard = [
[
types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
],
[
types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
],
# [
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
# ],
# [
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
# ],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
]
@@ -405,8 +405,7 @@ async def show_countries_management(
keyboard = [
[
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries"),
types.InlineKeyboardButton(text="⚙️ API настройки", callback_data="admin_rw_api")
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries")
],
[
types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"),
@@ -495,4 +494,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
handle_subscriptions_pagination,
F.data.startswith("admin_subs_list_page_")
)
)
+668 -3
View File
@@ -860,6 +860,606 @@ async def show_user_statistics(
)
await callback.answer()
@admin_required
@error_handler
async def extend_user_subscription(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(extending_user_id=user_id)
await callback.message.edit_text(
"⏰ <b>Продление подписки</b>\n\n"
"Введите количество дней для продления:\n"
"• Например: 30, 7, 90\n"
"• Максимум: 365 дней\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="7 дней", callback_data=f"admin_sub_extend_days_{user_id}_7"),
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_extend_days_{user_id}_30")
],
[
types.InlineKeyboardButton(text="90 дней", callback_data=f"admin_sub_extend_days_{user_id}_90"),
types.InlineKeyboardButton(text="180 дней", callback_data=f"admin_sub_extend_days_{user_id}_180")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
]
])
)
await state.set_state(AdminStates.extending_subscription)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_extension_days(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
days = int(parts[-1])
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
await callback.message.edit_text(
f"✅ Подписка пользователя продлена на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка продления подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_extension_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("extending_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
days = int(message.text.strip())
if days <= 0 or days > 365:
await message.answer("❌ Количество дней должно быть от 1 до 365")
return
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
await message.answer(
f"✅ Подписка пользователя продлена на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка продления подписки")
except ValueError:
await message.answer("❌ Введите корректное число дней")
return
await state.clear()
@admin_required
@error_handler
async def add_subscription_traffic(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(traffic_user_id=user_id)
await callback.message.edit_text(
"📊 <b>Добавление трафика</b>\n\n"
"Введите количество ГБ для добавления:\n"
"• Например: 50, 100, 500\n"
"• Максимум: 10000 ГБ\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="50 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_50"),
types.InlineKeyboardButton(text="100 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_100")
],
[
types.InlineKeyboardButton(text="500 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_500"),
types.InlineKeyboardButton(text="1000 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_1000")
],
[
types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_sub_traffic_add_{user_id}_0"),
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
]
])
)
await state.set_state(AdminStates.adding_traffic)
await callback.answer()
@admin_required
@error_handler
async def process_traffic_addition_button(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
gb = int(parts[-1])
success = await _add_subscription_traffic(db, user_id, gb, db_user.id)
if success:
traffic_text = "♾️ безлимитный" if gb == 0 else f"{gb} ГБ"
await callback.message.edit_text(
f"✅ К подписке пользователя добавлен трафик: {traffic_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка добавления трафика",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_traffic_addition_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("traffic_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
gb = int(message.text.strip())
if gb < 0 or gb > 10000:
await message.answer("❌ Количество ГБ должно быть от 0 до 10000 (0 = безлимит)")
return
success = await _add_subscription_traffic(db, user_id, gb, db_user.id)
if success:
traffic_text = "♾️ безлимитный" if gb == 0 else f"{gb} ГБ"
await message.answer(
f"✅ К подписке пользователя добавлен трафик: {traffic_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка добавления трафика")
except ValueError:
await message.answer("❌ Введите корректное число ГБ")
return
await state.clear()
@admin_required
@error_handler
async def deactivate_user_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
await callback.message.edit_text(
"🚫 <b>Деактивация подписки</b>\n\n"
"Вы уверены, что хотите деактивировать подписку этого пользователя?\n"
"Пользователь потеряет доступ к сервису.",
reply_markup=get_confirmation_keyboard(
f"admin_sub_deactivate_confirm_{user_id}",
f"admin_user_subscription_{user_id}",
db_user.language
)
)
await callback.answer()
@admin_required
@error_handler
async def confirm_subscription_deactivation(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
success = await _deactivate_user_subscription(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Подписка пользователя деактивирована",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка деактивации подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def activate_user_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
success = await _activate_user_subscription(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Подписка пользователя активирована",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка активации подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def grant_trial_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
success = await _grant_trial_subscription(db, user_id, db_user.id)
if success:
await callback.message.edit_text(
"✅ Пользователю выдан триальный период",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка выдачи триального периода",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def grant_paid_subscription(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(granting_user_id=user_id)
await callback.message.edit_text(
"💎 <b>Выдача подписки</b>\n\n"
"Введите количество дней подписки:\n"
"• Например: 30, 90, 180, 365\n"
"• Максимум: 730 дней\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_grant_days_{user_id}_30"),
types.InlineKeyboardButton(text="90 дней", callback_data=f"admin_sub_grant_days_{user_id}_90")
],
[
types.InlineKeyboardButton(text="180 дней", callback_data=f"admin_sub_grant_days_{user_id}_180"),
types.InlineKeyboardButton(text="365 дней", callback_data=f"admin_sub_grant_days_{user_id}_365")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
]
])
)
await state.set_state(AdminStates.granting_subscription)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_grant_days(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
days = int(parts[-1])
success = await _grant_paid_subscription(db, user_id, days, db_user.id)
if success:
await callback.message.edit_text(
f"✅ Пользователю выдана подписка на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка выдачи подписки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_subscription_grant_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("granting_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
days = int(message.text.strip())
if days <= 0 or days > 730:
await message.answer("❌ Количество дней должно быть от 1 до 730")
return
success = await _grant_paid_subscription(db, user_id, days, db_user.id)
if success:
await message.answer(
f"✅ Пользователю выдана подписка на {days} дней",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка выдачи подписки")
except ValueError:
await message.answer("❌ Введите корректное число дней")
return
await state.clear()
async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
await extend_subscription(db, subscription, days)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
return True
except Exception as e:
logger.error(f"Ошибка продления подписки: {e}")
return False
async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, add_subscription_traffic
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
if gb == 0:
subscription.traffic_limit_gb = 0
await db.commit()
else:
await add_subscription_traffic(db, subscription, gb)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
traffic_text = "безлимитный" if gb == 0 else f"{gb} ГБ"
logger.info(f"Админ {admin_id} добавил трафик {traffic_text} пользователю {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка добавления трафика: {e}")
return False
async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, deactivate_subscription
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
await deactivate_subscription(db, subscription)
user = await get_user_by_id(db, user_id)
if user and user.remnawave_uuid:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"Админ {admin_id} деактивировал подписку пользователя {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка деактивации подписки: {e}")
return False
async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id
from app.services.subscription_service import SubscriptionService
from app.database.models import SubscriptionStatus
from datetime import datetime
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
subscription.status = SubscriptionStatus.ACTIVE.value
if subscription.end_date <= datetime.utcnow():
subscription.end_date = datetime.utcnow() + timedelta(days=1)
await db.commit()
await db.refresh(subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} активировал подписку пользователя {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка активации подписки: {e}")
return False
async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, create_trial_subscription
from app.services.subscription_service import SubscriptionService
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
subscription = await create_trial_subscription(db, user_id)
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} выдал триальную подписку пользователю {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка выдачи триальной подписки: {e}")
return False
async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
from app.services.subscription_service import SubscriptionService
from app.config import settings
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=days,
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=settings.DEFAULT_DEVICE_LIMIT,
connected_squads=[settings.TRIAL_SQUAD_UUID] if settings.TRIAL_SQUAD_UUID else []
)
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} выдал платную подписку на {days} дней пользователю {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка выдачи платной подписки: {e}")
return False
@admin_required
@error_handler
async def cleanup_inactive_users(
@@ -925,7 +1525,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
confirm_user_block,
F.data.startswith("admin_user_block_")
F.data.startswith("admin_user_block_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
@@ -940,7 +1540,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
confirm_user_delete,
F.data.startswith("admin_user_delete_")
F.data.startswith("admin_user_delete_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
@@ -973,7 +1573,6 @@ def register_handlers(dp: Dispatcher):
AdminStates.editing_user_balance
)
dp.callback_query.register(
show_inactive_users,
F.data == "admin_users_inactive"
@@ -983,3 +1582,69 @@ def register_handlers(dp: Dispatcher):
cleanup_inactive_users,
F.data == "admin_cleanup_inactive"
)
dp.callback_query.register(
extend_user_subscription,
F.data.startswith("admin_sub_extend_") & ~F.data.contains("days") & ~F.data.contains("confirm")
)
dp.callback_query.register(
process_subscription_extension_days,
F.data.startswith("admin_sub_extend_days_")
)
dp.message.register(
process_subscription_extension_text,
AdminStates.extending_subscription
)
dp.callback_query.register(
add_subscription_traffic,
F.data.startswith("admin_sub_traffic_") & ~F.data.contains("add")
)
dp.callback_query.register(
process_traffic_addition_button,
F.data.startswith("admin_sub_traffic_add_")
)
dp.message.register(
process_traffic_addition_text,
AdminStates.adding_traffic
)
dp.callback_query.register(
deactivate_user_subscription,
F.data.startswith("admin_sub_deactivate_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
confirm_subscription_deactivation,
F.data.startswith("admin_sub_deactivate_confirm_")
)
dp.callback_query.register(
activate_user_subscription,
F.data.startswith("admin_sub_activate_")
)
dp.callback_query.register(
grant_trial_subscription,
F.data.startswith("admin_sub_grant_trial_")
)
dp.callback_query.register(
grant_paid_subscription,
F.data.startswith("admin_sub_grant_") & ~F.data.contains("trial") & ~F.data.contains("days")
)
dp.callback_query.register(
process_subscription_grant_days,
F.data.startswith("admin_sub_grant_days_")
)
dp.message.register(
process_subscription_grant_text,
AdminStates.granting_subscription
)
+212 -11
View File
@@ -55,15 +55,46 @@ async def show_balance_history(
offset = (page - 1) * TRANSACTIONS_PER_PAGE
transactions = await get_user_transactions(
raw_transactions = await get_user_transactions(
db, db_user.id,
limit=TRANSACTIONS_PER_PAGE,
limit=TRANSACTIONS_PER_PAGE * 3,
offset=offset
)
total_count = await get_user_transactions_count(db, db_user.id)
seen_transactions = set()
unique_transactions = []
if not transactions:
for transaction in raw_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_transactions:
seen_transactions.add(transaction_key)
unique_transactions.append(transaction)
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
break
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
seen_all = set()
total_unique = 0
for transaction in all_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_all:
seen_all.add(transaction_key)
total_unique += 1
if not unique_transactions:
await callback.message.edit_text(
"📊 История операций пуста",
reply_markup=get_back_keyboard(db_user.language)
@@ -73,7 +104,7 @@ async def show_balance_history(
text = "📊 <b>История операций</b>\n\n"
for transaction in transactions:
for transaction in unique_transactions:
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
@@ -82,7 +113,7 @@ async def show_balance_history(
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
keyboard = []
total_pages = (total_count + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
if total_pages > 1:
pagination_row = get_pagination_keyboard(
@@ -126,7 +157,7 @@ async def show_payment_methods(
Выберите удобный для вас способ оплаты:
<b>Telegram Stars</b> - быстро и удобно
💎 <b>Банковская карта</b> - через Tribute
💳 <b>Банковская карта</b> - через YooKassa/Tribute
🛠 <b>Через поддержку</b> - другие способы
Выберите способ пополнения:
@@ -162,6 +193,30 @@ async def start_stars_payment(
await callback.answer()
@error_handler
async def start_yookassa_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
return
await callback.message.edit_text(
"💳 <b>Оплата банковской картой</b>\n\n"
"Введите сумму для пополнения от 100 до 50,000 рублей:",
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa")
await callback.answer()
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
@@ -265,11 +320,11 @@ async def process_topup_amount(
amount_rubles = float(message.text.replace(',', '.'))
if amount_rubles < 1:
await message.answer("Минимальная сумма пополнения: 1 ₽")
await message.answer("Минимальная сумма пополнения: 1 ₽")
return
if amount_rubles > 50000:
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
return
amount_kopeks = int(amount_rubles * 100)
@@ -278,8 +333,12 @@ async def process_topup_amount(
if payment_method == "stars":
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
elif payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("Неизвестный способ оплаты")
await message.answer("Неизвестный способ оплаты")
except ValueError:
await message.answer(
@@ -287,7 +346,6 @@ async def process_topup_amount(
reply_markup=get_back_keyboard(db_user.language)
)
@error_handler
async def process_stars_payment_amount(
message: types.Message,
@@ -329,6 +387,139 @@ async def process_stars_payment_amount(
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(
@@ -356,6 +547,11 @@ def register_handlers(dp: Dispatcher):
F.data == "topup_stars"
)
dp.callback_query.register(
start_yookassa_payment,
F.data == "topup_yookassa"
)
dp.callback_query.register(
start_tribute_payment,
F.data == "topup_tribute"
@@ -366,6 +562,11 @@ def register_handlers(dp: Dispatcher):
F.data == "topup_support"
)
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
dp.message.register(
process_topup_amount,
BalanceStates.waiting_for_amount
+21 -12
View File
@@ -2,6 +2,7 @@ import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
@@ -108,25 +109,33 @@ async def handle_back_to_menu(
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return " Отсутствует"
return " Отсутствует"
if user.subscription.is_trial:
days_left = user.subscription.days_left
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📅 до {user.subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left == 1:
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
else:
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
elif user.subscription.is_active:
days_left = user.subscription.days_left
else:
if days_left > 7:
return f" Активна\n📅 до {user.subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left > 0:
return f" Активна\n⚠️ истекает через {days_left} дн."
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⚠️ истекает сегодня!"
else:
return f"⏰ Истекла\n📅 {user.subscription.end_date.strftime('%d.%m.%Y')}"
return f"💎 Активна\n⚠️ истекает сегодня!"
def register_handlers(dp: Dispatcher):
+2 -27
View File
@@ -85,13 +85,7 @@ async def create_invite_message(
[
types.InlineKeyboardButton(
text="📤 Поделиться",
switch_inline_query=invite_text
)
],
[
types.InlineKeyboardButton(
text="📋 Скопировать ссылку",
callback_data="copy_referral_link"
switch_inline_query=invite_text
)
],
[
@@ -104,7 +98,7 @@ async def create_invite_message(
await callback.message.edit_text(
f"📝 <b>Приглашение создано!</b>\n\n"
f"Используйте кнопку ниже для отправки приглашения или скопируйте текст:\n\n"
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
f"<code>{invite_text}</code>",
reply_markup=keyboard,
parse_mode="HTML"
@@ -112,20 +106,6 @@ async def create_invite_message(
await callback.answer()
async def copy_referral_link(
callback: types.CallbackQuery,
db_user: User
):
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
await callback.answer(
f"Ссылка скопирована: {referral_link}",
show_alert=True
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
@@ -137,8 +117,3 @@ def register_handlers(dp: Dispatcher):
create_invite_message,
F.data == "referral_create_invite"
)
dp.callback_query.register(
copy_referral_link,
F.data == "copy_referral_link"
)
+174 -2
View File
@@ -1,4 +1,5 @@
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
@@ -20,6 +21,62 @@ from app.utils.user_utils import generate_unique_referral_code
logger = logging.getLogger(__name__)
async def handle_potential_referral_code(
message: types.Message,
state: FSMContext,
db: AsyncSession
):
current_state = await state.get_state()
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
if current_state not in [
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state,
None
]:
return False
user = await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status == UserStatus.ACTIVE.value:
return False
potential_code = message.text.strip()
if len(potential_code) < 4 or len(potential_code) > 20:
return False
referrer = await get_user_by_referral_code(db, potential_code)
if not referrer:
await message.answer(
"❌ Неверный реферальный код.\n\n"
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start"
)
return True
data = await state.get_data() or {}
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer("✅ Реферальный код принят!")
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', 'ru')
texts = get_texts(language)
await message.answer(
texts.RULES_TEXT,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после ввода реферального кода")
else:
await complete_registration(message, state, db)
return True
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
@@ -36,6 +93,37 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if user and user.status != UserStatus.DELETED.value:
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
profile_updated = False
if user.username != message.from_user.username:
old_username = user.username
user.username = message.from_user.username
logger.info(f"📝 Username обновлен: '{old_username}''{user.username}'")
profile_updated = True
if user.first_name != message.from_user.first_name:
old_first_name = user.first_name
user.first_name = message.from_user.first_name
logger.info(f"📝 Имя обновлено: '{old_first_name}''{user.first_name}'")
profile_updated = True
if user.last_name != message.from_user.last_name:
old_last_name = user.last_name
user.last_name = message.from_user.last_name
logger.info(f"📝 Фамилия обновлена: '{old_last_name}''{user.last_name}'")
profile_updated = True
user.last_activity = datetime.utcnow()
if profile_updated:
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен")
else:
await db.commit()
texts = get_texts(user.language)
if referral_code and not user.referred_by_id:
@@ -107,6 +195,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.referred_by_id = None
user.username = message.from_user.username
user.first_name = message.from_user.first_name
user.last_name = message.from_user.last_name
user.updated_at = datetime.utcnow()
user.last_activity = datetime.utcnow()
from app.utils.user_utils import generate_unique_referral_code
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
@@ -299,6 +394,10 @@ async def complete_registration_from_callback(
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
texts = get_texts(existing_user.language)
data = await state.get_data()
if data.get('referral_code') and not existing_user.referred_by_id:
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
has_active_subscription = existing_user.subscription is not None
subscription_is_active = False
@@ -362,7 +461,7 @@ async def complete_registration_from_callback(
user = existing_user
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
else:
elif not existing_user:
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
@@ -377,6 +476,20 @@ async def complete_registration_from_callback(
referred_by_id=referrer_id,
referral_code=referral_code
)
else:
logger.info(f"🔄 Обновляем существующего пользователя {callback.from_user.id}")
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
user = existing_user
if referrer_id:
try:
@@ -451,6 +564,42 @@ async def complete_registration(
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
try:
await message.answer(
texts.MAIN_MENU.format(
user_name=existing_user.full_name,
balance=texts.format_price(existing_user.balance_kopeks),
subscription_status=_get_subscription_status(existing_user, texts)
),
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
is_admin=settings.is_admin(existing_user.telegram_id),
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
)
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await message.answer(f"Добро пожаловать, {existing_user.full_name}!")
await state.clear()
return
data = await state.get_data()
language = data.get('language', 'ru')
texts = get_texts(language)
@@ -483,7 +632,7 @@ async def complete_registration(
user = existing_user
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
else:
elif not existing_user:
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
referral_code = await generate_unique_referral_code(db, message.from_user.id)
@@ -498,6 +647,20 @@ async def complete_registration(
referred_by_id=referrer_id,
referral_code=referral_code
)
else:
logger.info(f"🔄 Обновляем существующего пользователя {message.from_user.id}")
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
user = existing_user
if referrer_id:
try:
@@ -614,4 +777,13 @@ def register_handlers(dp: Dispatcher):
)
logger.info("✅ Зарегистрирован process_referral_code_input")
dp.message.register(
handle_potential_referral_code,
StateFilter(
RegistrationStates.waiting_for_rules_accept,
RegistrationStates.waiting_for_referral_code
)
)
logger.info("✅ Зарегистрирован handle_potential_referral_code")
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
+437 -137
View File
@@ -6,7 +6,7 @@ from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
import json
import os
from typing import Dict, List, Any, Tuple
from typing import Dict, List, Any, Tuple, Optional
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.states import SubscriptionStates
@@ -62,57 +62,88 @@ async def show_subscription_info(
await callback.answer()
return
from app.database.crud.subscription import check_and_update_subscription_status
subscription = await check_and_update_subscription_status(db, subscription)
subscription_service = SubscriptionService()
await subscription_service.sync_subscription_usage(db, subscription)
await db.refresh(subscription)
devices_used = await get_current_devices_count(db_user)
current_time = datetime.utcnow()
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..."
if subscription.is_trial:
status_text = "🎁 Тестовая"
type_text = "Триал"
else:
if subscription.is_active:
status_text = "✅ Оплачена"
if subscription.status == "expired" or subscription.end_date <= current_time:
actual_status = "expired"
status_display = "Истекла"
status_emoji = "🔴"
elif subscription.status == "active" and subscription.end_date > current_time:
if subscription.is_trial:
actual_status = "trial_active"
status_display = "Тестовая"
status_emoji = "🎁"
else:
status_text = "❌ Истекла"
type_text = "Платная подписка"
actual_status = "paid_active"
status_display = "Активна"
status_emoji = "💎"
else:
actual_status = "unknown"
status_display = "Неизвестно"
status_emoji = ""
if subscription.end_date <= current_time:
days_left = 0
time_left_text = "истёк"
warning_text = ""
else:
delta = subscription.end_date - current_time
days_left = delta.days
hours_left = delta.seconds // 3600
if days_left > 1:
time_left_text = f"{days_left} дн."
warning_text = ""
elif days_left == 1:
time_left_text = f"{days_left} дн."
warning_text = "\n⚠️ истекает завтра!"
elif hours_left > 0:
time_left_text = f"{hours_left} ч."
warning_text = "\n⚠️ истекает сегодня!"
else:
minutes_left = (delta.seconds % 3600) // 60
time_left_text = f"{minutes_left} мин."
warning_text = "\n🔴 истекает через несколько минут!"
subscription_type = "Триал" if subscription.is_trial else "Платная"
if subscription.traffic_limit_gb == 0:
traffic_text = "∞ (безлимит)"
traffic_used_display = f"∞ (безлимит) / {subscription.traffic_used_gb:.1f} ГБ"
else:
traffic_text = texts.format_traffic(subscription.traffic_limit_gb)
traffic_used_display = f"{subscription.traffic_used_gb:.1f} / {subscription.traffic_limit_gb} ГБ"
subscription_cost = await get_subscription_cost(subscription, db)
devices_used = await get_current_devices_count(db_user)
info_text = texts.SUBSCRIPTION_INFO.format(
status=status_text,
type=type_text,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
devices_used=devices_used,
devices_limit=subscription.device_limit,
autopay_status="✅ Включен" if subscription.autopay_enabled else "❌ Выключен"
)
message = f"""👤 {db_user.full_name}
💰 Баланс: {settings.format_price(db_user.balance_kopeks)}
📱 Подписка: {status_emoji} {status_display}{warning_text}
📱 Информация о подписке
🎭 Тип: {subscription_type}
📅 Действует до: {subscription.end_date.strftime("%d.%m.%Y %H:%M")}
Осталось: {time_left_text}
📈 Трафик: {traffic_used_display}
🌍 Серверы: {len(subscription.connected_squads)} стран
📱 Устройства: {devices_used} / {subscription.device_limit}"""
if subscription_cost > 0:
info_text += f"\n💰 <b>Стоимость подписки:</b> {texts.format_price(subscription_cost)}"
if subscription_url and subscription_url != "Генерируется...":
info_text += f"\n\n🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>"
info_text += f"\n\n📱 Скопируйте ссылку и добавьте в ваше VPN приложение"
if hasattr(subscription, 'subscription_url') and subscription.subscription_url:
if actual_status in ['trial_active', 'paid_active']:
message += f"\n\n🔗 <b>Ссылка для подключения:</b>\n<code>{subscription.subscription_url}</code>"
message += f"\n\n📱 Скопируйте ссылку и добавьте в ваше VPN приложение"
await callback.message.edit_text(
info_text,
message,
reply_markup=get_subscription_keyboard(
db_user.language,
has_subscription=True,
@@ -155,35 +186,33 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
subscription_service = SubscriptionService()
base_cost = PERIOD_PRICES.get(30, 0)
try:
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads, db
)
except AttributeError:
logger.warning("Используем fallback для расчета стоимости серверов")
servers_cost, _ = await get_countries_price_by_uuids_fallback(
subscription.connected_squads, db
)
traffic_cost = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
devices_cost = max(0, subscription.device_limit - 1) * settings.PRICE_PER_DEVICE
base_cost = min(PERIOD_PRICES.values()) if PERIOD_PRICES else 0
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
logger.info(f"📊 Расчет стоимости подписки {subscription.id} (по текущим ценам):")
logger.info(f" 📦 Базовая стоимость: {base_cost/100}")
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_cost/100}")
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_cost/100}")
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_cost/100}")
logger.info(f" 💎 ОБЩАЯ СТОИМОСТЬ: {total_cost/100}")
logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:")
logger.info(f" 📅 Базовый тариф (30 дней): {base_cost/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}")
logger.error(f" Ошибка расчета стоимости подписки: {e}")
return 0
@@ -289,7 +318,6 @@ async def start_subscription_purchase(
state: FSMContext,
db_user: User
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
@@ -297,14 +325,19 @@ async def start_subscription_purchase(
reply_markup=get_subscription_period_keyboard(db_user.language)
)
await state.set_data({
initial_data = {
'period_days': None,
'traffic_gb': None,
'countries': [],
'devices': 1,
'total_price': 0
})
}
if settings.is_traffic_fixed():
initial_data['traffic_gb'] = settings.get_fixed_traffic_limit()
else:
initial_data['traffic_gb'] = None
await state.set_data(initial_data)
await state.set_state(SubscriptionStates.selecting_period)
await callback.answer()
@@ -316,6 +349,9 @@ async def handle_add_countries(
db: AsyncSession,
state: FSMContext
):
if not await _should_show_countries_management():
await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -333,7 +369,7 @@ async def handle_add_countries(
current_countries_names.append(country['name'])
text = "🌍 <b>Управление странами подписки</b>\n\n"
text += f"📍 <b>Текущие страны ({len(current_countries)}):</b>\n"
text += f"📋 <b>Текущие страны ({len(current_countries)}):</b>\n"
if current_countries_names:
text += "\n".join(f"{name}" for name in current_countries_names)
else:
@@ -575,16 +611,21 @@ async def handle_add_traffic(
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть изменен", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
return
if subscription.traffic_limit_gb == 0:
await callback.answer(" У вас уже безлимитный трафик", show_alert=True)
await callback.answer(" У вас уже безлимитный трафик", show_alert=True)
return
current_traffic = subscription.traffic_limit_gb
@@ -629,36 +670,57 @@ async def handle_extend_subscription(
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Продление доступно только для платных подписок", show_alert=True)
await callback.answer(" Продление доступно только для платных подписок", show_alert=True)
return
if subscription.days_left > 3:
await callback.answer(" Продление доступно за 3 дня до окончания подписки", show_alert=True)
await callback.answer(" Продление доступно за 3 дня до окончания подписки", show_alert=True)
return
subscription_service = SubscriptionService()
available_periods = settings.get_available_renewal_periods()
renewal_prices = {}
for days in [30, 90, 180]:
price = await subscription_service.calculate_renewal_price(subscription, days, db)
renewal_prices[days] = price
for days in available_periods:
try:
price = await subscription_service.calculate_renewal_price(subscription, days, db)
renewal_prices[days] = price
except Exception as e:
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
continue
if not renewal_prices:
await callback.answer("⌛ Нет доступных периодов для продления", show_alert=True)
return
prices_text = ""
period_display = {
14: "14 дней",
30: "30 дней",
60: "60 дней",
90: "90 дней",
180: "180 дней",
360: "360 дней"
}
for days in available_periods:
if days in renewal_prices and days in period_display:
prices_text += f"📅 {period_display[days]} - {texts.format_price(renewal_prices[days])}\n"
await callback.message.edit_text(
f"<b>Продление подписки</b>\n\n"
f"⏰ Продление подписки\n\n"
f"Осталось дней: {subscription.days_left}\n\n"
f"<b>Ваша текущая конфигурация:</b>\n"
f"🌍 Серверов: {len(subscription.connected_squads)}\n"
f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}\n"
f"📱 Устройств: {subscription.device_limit}\n\n"
f"<b>Выберите период продления:</b>\n"
f"📅 30 дней - {texts.format_price(renewal_prices[30])}\n"
f"📅 90 дней - {texts.format_price(renewal_prices[90])}\n"
f"📅 180 дней - {texts.format_price(renewal_prices[180])}\n\n"
f"{prices_text.rstrip()}\n\n"
f"💡 <i>Цена включает все ваши текущие серверы и настройки</i>",
reply_markup=get_extend_subscription_keyboard_with_prices(db_user.language, renewal_prices),
parse_mode="HTML"
@@ -672,22 +734,27 @@ async def handle_reset_traffic(
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
return
if subscription.traffic_limit_gb == 0:
await callback.answer(" У вас безлимитный трафик", show_alert=True)
await callback.answer(" У вас безлимитный трафик", show_alert=True)
return
reset_price = PERIOD_PRICES[30]
if db_user.balance_kopeks < reset_price:
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
return
await callback.message.edit_text(
@@ -708,6 +775,11 @@ async def confirm_add_traffic(
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
@@ -716,7 +788,7 @@ async def confirm_add_traffic(
price = TRAFFIC_PRICES[traffic_gb]
if db_user.balance_kopeks < price:
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
return
try:
@@ -726,7 +798,7 @@ async def confirm_add_traffic(
)
if not success:
await callback.answer(" Ошибка списания средств", show_alert=True)
await callback.answer(" Ошибка списания средств", show_alert=True)
return
if traffic_gb == 0:
@@ -858,19 +930,30 @@ async def confirm_extend_subscription(
db_user: User,
db: AsyncSession
):
days = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
await callback.answer("❌ У вас нет активной подписки", show_alert=True)
return
subscription_service = SubscriptionService()
price = await subscription_service.calculate_renewal_price(subscription, days, db)
try:
price = await subscription_service.calculate_renewal_price(subscription, days, db)
except Exception as e:
logger.error(f"❌ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
await callback.answer("❌ Ошибка расчета стоимости", show_alert=True)
return
if db_user.balance_kopeks < price:
await callback.answer("❌ Недостаточно средств на балансе", show_alert=True)
return
try:
logger.info(f"🔄 Начинаем продление подписки {subscription.id} на {days} дней за {price/100}")
success = await subtract_user_balance(
db, db_user, price,
f"Продление подписки на {days} дней"
@@ -880,18 +963,40 @@ async def confirm_extend_subscription(
await callback.answer("❌ Ошибка списания средств", show_alert=True)
return
await extend_subscription(db, subscription, days)
current_time = datetime.utcnow()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
if subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
else:
subscription.end_date = current_time + timedelta(days=days)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Продление подписки на {days} дней"
)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
await db.refresh(db_user)
try:
remnawave_result = await subscription_service.update_remnawave_user(db, subscription)
if remnawave_result:
logger.info(f"✅ RemnaWave обновлен успешно")
else:
logger.error(f"❌ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
except Exception as e:
logger.error(f"❌ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
try:
transaction = await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Продление подписки на {days} дней"
)
logger.info(f"✅ Транзакция создана: ID {transaction.id}")
except Exception as e:
logger.error(f"❌ ОШИБКА СОЗДАНИЯ ТРАНЗАКЦИИ: {e}")
try:
await process_referral_purchase(
@@ -900,11 +1005,9 @@ async def confirm_extend_subscription(
purchase_amount_kopeks=price,
transaction_id=None
)
logger.info(f"✅ Рефералы обработаны")
except Exception as e:
logger.error(f"Ошибка обработки реферальной покупки: {e}")
await db.refresh(db_user)
await db.refresh(subscription)
logger.error(f"❌ ОШИБКА ОБРАБОТКИ РЕФЕРАЛОВ: {e}")
await callback.message.edit_text(
f"✅ Подписка успешно продлена!\n\n"
@@ -917,9 +1020,12 @@ async def confirm_extend_subscription(
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}")
except Exception as e:
logger.error(f"Ошибка продления подписки: {e}")
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
import traceback
logger.error(f"TRACEBACK: {traceback.format_exc()}")
await callback.message.edit_text(
texts.ERROR,
"❌ Произошла ошибка при продлении подписки. Обратитесь в поддержку.",
reply_markup=get_back_keyboard(db_user.language)
)
@@ -959,6 +1065,11 @@ async def confirm_reset_traffic(
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -966,7 +1077,7 @@ async def confirm_reset_traffic(
reset_price = PERIOD_PRICES[30]
if db_user.balance_kopeks < reset_price:
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
return
try:
@@ -976,7 +1087,7 @@ async def confirm_reset_traffic(
)
if not success:
await callback.answer(" Ошибка списания средств", show_alert=True)
await callback.answer(" Ошибка списания средств", show_alert=True)
return
subscription.traffic_used_gb = 0.0
@@ -1027,30 +1138,120 @@ async def select_period(
state: FSMContext,
db_user: User
):
period_days = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
data = await state.get_data()
data['period_days'] = period_days
data['total_price'] = PERIOD_PRICES[period_days]
if settings.is_traffic_fixed():
fixed_traffic_price = TRAFFIC_PRICES.get(settings.get_fixed_traffic_limit(), 0)
data['total_price'] += fixed_traffic_price
await state.set_data(data)
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
if settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
if await _should_show_countries_management():
countries = await _get_available_countries()
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries()
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(1, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await state.set_state(SubscriptionStates.selecting_traffic)
await callback.answer()
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
devices_used = await get_current_devices_count(db_user)
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..."
if subscription.is_trial:
status_text = "🎁 Тестовая"
type_text = "Триал"
else:
if subscription.is_active:
status_text = "✅ Оплачена"
else:
status_text = "⌛ Истекла"
type_text = "Платная подписка"
if subscription.traffic_limit_gb == 0:
if settings.is_traffic_fixed():
traffic_text = "∞ Безлимитный"
else:
traffic_text = "∞ Безлимитный"
else:
if settings.is_traffic_fixed():
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
else:
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
subscription_cost = await get_subscription_cost(subscription, db)
info_text = texts.SUBSCRIPTION_INFO.format(
status=status_text,
type=type_text,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
devices_used=devices_used,
devices_limit=subscription.device_limit,
autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен"
)
if subscription_cost > 0:
info_text += f"\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}"
if subscription_url and subscription_url != "Генерируется...":
info_text += f"\n\n🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>"
info_text += f"\n\n📱 Скопируйте ссылку и добавьте в ваше VPN приложение"
return info_text
def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
if is_fixed_mode is None:
is_fixed_mode = settings.is_traffic_fixed()
if traffic_gb == 0:
if is_fixed_mode:
return "Безлимитный"
else:
return "Безлимитный"
else:
if is_fixed_mode:
return f"{traffic_gb} ГБ"
else:
return f"{traffic_gb} ГБ"
async def select_traffic(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
traffic_gb = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
@@ -1059,14 +1260,25 @@ async def select_traffic(
data['total_price'] += TRAFFIC_PRICES[traffic_gb]
await state.set_data(data)
countries = await _get_available_countries()
if await _should_show_countries_management():
countries = await _get_available_countries()
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries()
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(1, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
await callback.answer()
@@ -1168,16 +1380,14 @@ async def select_devices(
)
await callback.answer()
async def devices_continue(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
if not callback.data == "devices_continue":
await callback.answer(" Некорректный запрос", show_alert=True)
await callback.answer(" Некорректный запрос", show_alert=True)
return
data = await state.get_data()
@@ -1197,24 +1407,47 @@ async def devices_continue(
if country['uuid'] in data['countries']:
selected_countries_names.append(country['name'])
base_price = PERIOD_PRICES[data['period_days']] + TRAFFIC_PRICES[data['traffic_gb']]
base_price = PERIOD_PRICES[data['period_days']]
if settings.is_traffic_fixed():
traffic_price = TRAFFIC_PRICES.get(data['traffic_gb'], 0)
else:
traffic_price = TRAFFIC_PRICES.get(data['traffic_gb'], 0)
devices_price = (data['devices'] - 1) * settings.PRICE_PER_DEVICE
total_price = base_price + countries_price + devices_price
total_price = base_price + traffic_price + countries_price + devices_price
data['total_price'] = total_price
await state.set_data(data)
summary_text = texts.SUBSCRIPTION_SUMMARY.format(
period=data['period_days'],
traffic=texts.format_traffic(data['traffic_gb']),
countries=", ".join(selected_countries_names),
devices=data['devices'],
total_price=texts.format_price(total_price)
)
if settings.is_traffic_fixed():
if data['traffic_gb'] == 0:
traffic_display = "Безлимитный"
else:
traffic_display = f"{data['traffic_gb']} ГБ"
else:
if data['traffic_gb'] == 0:
traffic_display = "Безлимитный"
else:
traffic_display = f"{data['traffic_gb']} ГБ"
summary_text = f"""
📋 <b>Сводка заказа</b>
📅 <b>Период:</b> {data['period_days']} дней
📊 <b>Трафик:</b> {traffic_display}
🌍 <b>Страны:</b> {", ".join(selected_countries_names)}
📱 <b>Устройства:</b> {data['devices']}
💰 <b>Общая стоимость:</b> {texts.format_price(total_price)}
Подтверждаете покупку?
"""
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard(db_user.language)
reply_markup=get_subscription_confirm_keyboard(db_user.language),
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.confirming_purchase)
@@ -1232,7 +1465,7 @@ async def confirm_purchase(
countries = await _get_available_countries()
base_price = PERIOD_PRICES[data['period_days']] + TRAFFIC_PRICES[data['traffic_gb']]
base_price = PERIOD_PRICES[data['period_days']]
countries_price = 0
server_prices = []
@@ -1242,7 +1475,15 @@ async def confirm_purchase(
server_prices.append(country['price_kopeks'])
devices_price = (data['devices'] - 1) * settings.PRICE_PER_DEVICE
final_price = base_price + countries_price + devices_price
if settings.is_traffic_fixed():
traffic_price = TRAFFIC_PRICES.get(settings.get_fixed_traffic_limit(), 0)
final_traffic_gb = settings.get_fixed_traffic_limit()
else:
traffic_price = TRAFFIC_PRICES.get(data['traffic_gb'], 0)
final_traffic_gb = data['traffic_gb']
final_price = base_price + traffic_price + countries_price + devices_price
if db_user.balance_kopeks < final_price:
await callback.message.edit_text(
@@ -1268,34 +1509,36 @@ async def confirm_purchase(
existing_subscription = db_user.subscription
if existing_subscription and existing_subscription.is_trial:
logger.info(f"🔄 Обновляем триальную подписку пользователя {db_user.telegram_id}")
if existing_subscription:
logger.info(f"🔄 Обновляем существующую подписку пользователя {db_user.telegram_id}")
existing_subscription.is_trial = False
existing_subscription.status = SubscriptionStatus.ACTIVE.value
existing_subscription.traffic_limit_gb = data['traffic_gb']
existing_subscription.traffic_limit_gb = final_traffic_gb
existing_subscription.device_limit = data['devices']
existing_subscription.connected_squads = data['countries']
existing_subscription.extend_subscription(data['period_days'])
existing_subscription.start_date = datetime.utcnow()
existing_subscription.end_date = datetime.utcnow() + timedelta(days=data['period_days'])
existing_subscription.updated_at = datetime.utcnow()
existing_subscription.traffic_used_gb = 0.0
await db.commit()
await db.refresh(existing_subscription)
subscription = existing_subscription
logger.info(f"Триальная подписка обновлена до платной. Новая дата окончания: {subscription.end_date}")
logger.info(f"Подписка обновлена. Новая дата окончания: {subscription.end_date}")
else:
logger.info(f"🆕 Создаем новую платную подписку для пользователя {db_user.telegram_id}")
subscription = await create_paid_subscription(
logger.info(f"🆕 Создаем новую подписку для пользователя {db_user.telegram_id}")
subscription = await create_paid_subscription_with_traffic_mode(
db=db,
user_id=db_user.id,
duration_days=data['period_days'],
traffic_limit_gb=data['traffic_gb'],
device_limit=data['devices'],
connected_squads=data['countries']
connected_squads=data['countries'],
traffic_gb=final_traffic_gb
)
from app.utils.user_utils import mark_user_as_had_paid_subscription
@@ -1317,9 +1560,16 @@ async def confirm_purchase(
subscription_service = SubscriptionService()
if existing_subscription and existing_subscription.is_trial == False:
if db_user.remnawave_uuid:
logger.info(f"🔄 Обновляем существующего RemnaWave пользователя {db_user.remnawave_uuid}")
remnawave_user = await subscription_service.update_remnawave_user(db, subscription)
else:
logger.info(f"🆕 Создаем нового RemnaWave пользователя для {db_user.telegram_id}")
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
if not remnawave_user:
logger.error(f"❌ Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
logger.info(f"🔄 Fallback: принудительное создание нового RemnaWave пользователя")
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
await create_transaction(
@@ -1384,6 +1634,39 @@ async def confirm_purchase(
await state.clear()
await callback.answer()
async def create_paid_subscription_with_traffic_mode(
db: AsyncSession,
user_id: int,
duration_days: int,
device_limit: int,
connected_squads: List[str],
traffic_gb: Optional[int] = None
):
from app.config import settings
from app.database.crud.subscription import create_paid_subscription
if traffic_gb is None:
if settings.is_traffic_fixed():
traffic_limit_gb = settings.get_fixed_traffic_limit()
else:
traffic_limit_gb = 0
else:
traffic_limit_gb = traffic_gb
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads
)
logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
return subscription
async def handle_subscription_settings(
callback: types.CallbackQuery,
db_user: User,
@@ -1409,9 +1692,11 @@ async def handle_subscription_settings(
Выберите что хотите изменить:
"""
show_countries = await _should_show_countries_management()
await callback.message.edit_text(
settings_text,
reply_markup=get_subscription_settings_keyboard(db_user.language),
reply_markup=get_subscription_settings_keyboard(db_user.language, show_countries),
parse_mode="HTML"
)
await callback.answer()
@@ -1495,7 +1780,6 @@ async def handle_subscription_config_back(
db_user: User,
db: AsyncSession
):
current_state = await state.get_state()
texts = get_texts(db_user.language)
@@ -1507,11 +1791,18 @@ async def handle_subscription_config_back(
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_countries.state:
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
if settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
texts.BUY_SUBSCRIPTION_START,
reply_markup=get_subscription_period_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_devices.state:
countries = await _get_available_countries()
@@ -1725,6 +2016,15 @@ async def handle_add_country_to_subscription(
await callback.answer()
async def _should_show_countries_management() -> bool:
try:
countries = await _get_available_countries()
available_countries = [c for c in countries if c.get('is_available', True)]
return len(available_countries) > 1
except Exception as e:
logger.error(f"Ошибка проверки доступных серверов: {e}")
return True
async def confirm_add_countries_to_subscription(
callback: types.CallbackQuery,
+33 -1
View File
@@ -25,7 +25,8 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave")
],
[
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics")
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"),
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
@@ -575,3 +576,34 @@ def get_admin_pagination_keyboard(
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup:
"""Клавиатура для управления техработами"""
if language == "en":
toggle_text = "🔴 Disable maintenance" if is_active else "🔧 Enable maintenance"
monitoring_text = "⏹️ Stop monitoring" if monitoring_active else "🔄 Start monitoring"
check_api_text = "🔍 Check API"
back_text = "⬅️ Back to admin"
else:
toggle_text = "🔴 Выключить техработы" if is_active else "🔧 Включить техработы"
monitoring_text = "⏹️ Остановить мониторинг" if monitoring_active else "🔄 Запустить мониторинг"
check_api_text = "🔍 Проверить API"
back_text = "⬅️ Назад в админку"
keyboard = [
[InlineKeyboardButton(text=toggle_text, callback_data="maintenance_toggle")],
[InlineKeyboardButton(text=monitoring_text, callback_data="maintenance_monitoring")],
[InlineKeyboardButton(text=check_api_text, callback_data="maintenance_check_api")],
[InlineKeyboardButton(text=back_text, callback_data="admin_panel")]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
keyboard = [
[InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+134 -66
View File
@@ -107,9 +107,9 @@ def get_subscription_keyboard(
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
button_text = "🚀 Подключить подписку"
button_text = "🚀 Открыть в мини-приложении"
elif connect_mode == "miniapp_custom":
button_text = "🚀 Подключить подписку"
button_text = "🚀 Открыть приложение"
else:
button_text = "🔗 Подключиться"
@@ -142,28 +142,37 @@ def get_subscription_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_subscription_settings_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
def get_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
keyboard = [
[
keyboard = []
if show_countries_management:
keyboard.append([
InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries")
],
[
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
],
[
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
],
])
keyboard.extend([
[
InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices")
],
[
InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
]
])
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)
@@ -182,19 +191,25 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark
texts = get_texts(language)
keyboard = []
periods = [
(14, texts.PERIOD_14_DAYS),
(30, texts.PERIOD_30_DAYS),
(60, texts.PERIOD_60_DAYS),
(90, texts.PERIOD_90_DAYS),
(180, texts.PERIOD_180_DAYS),
(360, texts.PERIOD_360_DAYS)
]
available_periods = settings.get_available_subscription_periods()
for days, text in periods:
keyboard.append([
InlineKeyboardButton(text=text, callback_data=f"period_{days}")
])
period_texts = {
14: texts.PERIOD_14_DAYS,
30: texts.PERIOD_30_DAYS,
60: texts.PERIOD_60_DAYS,
90: texts.PERIOD_90_DAYS,
180: texts.PERIOD_180_DAYS,
360: texts.PERIOD_360_DAYS
}
for days in available_periods:
if days in period_texts:
keyboard.append([
InlineKeyboardButton(
text=period_texts[days],
callback_data=f"period_{days}"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
@@ -204,6 +219,11 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
keyboard = []
@@ -276,9 +296,11 @@ def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMa
price_text = f" (+{texts.format_price(price)})" if price > 0 else ""
emoji = "" if devices == current else ""
device_word = _get_device_declension(devices)
keyboard.append([
InlineKeyboardButton(
text=f"{emoji} {devices} устройство{_get_device_suffix(devices)}{price_text}",
text=f"{emoji} {devices} {device_word}{price_text}",
callback_data=f"devices_{devices}"
)
])
@@ -290,6 +312,13 @@ def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMa
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def _get_device_declension(count: int) -> str:
if count % 10 == 1 and count % 100 != 11:
return "устройство"
elif count % 10 in [2, 3, 4] and count % 100 not in [12, 13, 14]:
return "устройства"
else:
return "устройств"
def get_subscription_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -318,7 +347,6 @@ def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
"""Клавиатура выбора способа оплаты"""
texts = get_texts(language)
keyboard = []
@@ -330,10 +358,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
)
])
if settings.is_yookassa_enabled():
keyboard.append([
InlineKeyboardButton(
text="💳 Банковская карта (YooKassa)",
callback_data="topup_yookassa"
)
])
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="💳 Банковская карта",
text="💎 Банковская карта (Tribute)",
callback_data="topup_tribute"
)
])
@@ -351,6 +387,34 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_yookassa_payment_keyboard(
payment_id: str,
amount_kopeks: int,
confirmation_url: str,
language: str = "ru"
) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="💳 Оплатить",
url=confirmation_url
)
],
[
InlineKeyboardButton(
text="📊 Проверить статус",
callback_data=f"check_yookassa_status_{payment_id}"
)
],
[
InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)
]
])
def get_autopay_notification_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -449,16 +513,6 @@ def get_pagination_keyboard(
return keyboard
def _get_device_suffix(count: int) -> str:
if count == 1:
return ""
elif 2 <= count <= 4:
return "а"
else:
return ""
def get_confirmation_keyboard(
confirm_data: str,
cancel_data: str = "cancel",
@@ -540,6 +594,11 @@ def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
keyboard = []
@@ -574,9 +633,11 @@ def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> Inli
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} устройство{_get_device_suffix(add_count)} (итого: {total_devices}) - {settings.format_price(price)}",
text=f"📱 +{add_count} {add_device_word} (итого: {total_devices}) - {settings.format_price(price)}",
callback_data=f"add_devices_{add_count}"
)
])
@@ -589,6 +650,11 @@ def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> Inli
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
@@ -598,7 +664,7 @@ def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru")
)
],
[
InlineKeyboardButton(text=" Отмена", callback_data="menu_subscription")
InlineKeyboardButton(text=" Отмена", callback_data="menu_subscription")
]
])
@@ -814,29 +880,31 @@ def get_specific_app_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
from app.localization.texts import get_texts
texts = get_texts(language)
keyboard = []
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=f"📅 30 дней - {texts.format_price(prices[30])}",
callback_data="extend_period_30"
)
],
[
InlineKeyboardButton(
text=f"📅 90 дней - {texts.format_price(prices[90])}",
callback_data="extend_period_90"
)
],
[
InlineKeyboardButton(
text=f"📅 180 дней - {texts.format_price(prices[180])}",
callback_data="extend_period_180"
)
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
available_periods = settings.get_available_renewal_periods()
period_display = {
14: "14 дней",
30: "30 дней",
60: "60 дней",
90: "90 дней",
180: "180 дней",
360: "360 дней"
}
for days in available_periods:
if days in prices and days in period_display:
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display[days]} - {texts.format_price(prices[days])}",
callback_data=f"extend_period_{days}"
)
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+22 -1
View File
@@ -280,10 +280,31 @@ class RussianTexts(Texts):
Поддержка до 3 устройств
Успейте оформить до окончания тестового периода!
"""
MAINTENANCE_MODE_ACTIVE = """
🔧 Технические работы!
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
Ориентировочное время завершения: неизвестно
🔄 Попробуйте позже
Приносим извинения за временные неудобства.
"""
MAINTENANCE_MODE_API_ERROR = """
🔧 Технические работы!
Сервис временно недоступен из-за проблем с подключением к серверам.
Мы работаем над восстановлением. Попробуйте через несколько минут.
🔄 Последняя проверка: {last_check}
"""
SUBSCRIPTION_EXPIRING_PAID = """
<b>Подписка истекает через {days} дней!</b>
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {end_date}.
+28 -2
View File
@@ -1,4 +1,5 @@
import logging
from datetime import datetime
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser
@@ -56,7 +57,7 @@ class AuthMiddleware(BaseMiddleware):
)
if is_registration_process:
logger.info(f"📝 Пропускаем пользователя {user.id} в процессе регистрации")
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
data['db'] = db
data['db_user'] = None
data['is_admin'] = False
@@ -124,8 +125,33 @@ class AuthMiddleware(BaseMiddleware):
logger.info(f"❌ Удаленный пользователь {user.id} попытался использовать бота без /start")
return
from datetime import datetime
profile_updated = False
if db_user.username != user.username:
old_username = db_user.username
db_user.username = user.username
logger.info(f"📝 [Middleware] Username обновлен для {user.id}: '{old_username}''{db_user.username}'")
profile_updated = True
if db_user.first_name != user.first_name:
old_first_name = db_user.first_name
db_user.first_name = user.first_name
logger.info(f"📝 [Middleware] Имя обновлено для {user.id}: '{old_first_name}''{db_user.first_name}'")
profile_updated = True
if db_user.last_name != user.last_name:
old_last_name = db_user.last_name
db_user.last_name = user.last_name
logger.info(f"📝 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}''{db_user.last_name}'")
profile_updated = True
db_user.last_activity = datetime.utcnow()
if profile_updated:
db_user.updated_at = datetime.utcnow()
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
await db.commit()
data['db'] = db
+45
View File
@@ -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
+53
View File
@@ -0,0 +1,53 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from datetime import datetime
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
from app.database.database import get_db
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import SubscriptionStatus
logger = logging.getLogger(__name__)
class SubscriptionStatusMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
telegram_id = None
if isinstance(event, (Message, CallbackQuery)):
telegram_id = event.from_user.id
elif isinstance(event, Update):
if event.message:
telegram_id = event.message.from_user.id
elif event.callback_query:
telegram_id = event.callback_query.from_user.id
if telegram_id:
try:
async for db in get_db():
user = await get_user_by_telegram_id(db, telegram_id)
if user and user.subscription:
current_time = datetime.utcnow()
subscription = user.subscription
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
await db.commit()
logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)")
break
except Exception as e:
logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}")
return await handler(event, data)
+406
View File
@@ -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()
+74 -9
View File
@@ -22,6 +22,10 @@ from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
from app.localization.texts import get_texts
from app.external.remnawave_api import (
RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
)
logger = logging.getLogger(__name__)
@@ -91,7 +95,8 @@ class MonitoringService:
expired_subscriptions = await get_expired_subscriptions(db)
for subscription in expired_subscriptions:
await deactivate_subscription(db, subscription)
from app.database.crud.subscription import expire_subscription
await expire_subscription(db, subscription)
user = await get_user_by_id(db, subscription.user_id)
if user and user.remnawave_uuid:
@@ -100,17 +105,65 @@ class MonitoringService:
if user and self.bot:
await self._send_subscription_expired_notification(user)
logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и деактивирована")
logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и статус изменен на 'expired'")
if expired_subscriptions:
await self._log_monitoring_event(
db, "expired_subscriptions_processed",
f"Обработано {len(expired_subscriptions)} истекших подписок",
f"Обработано {len(expired_subscriptions)} истёкших подписок",
{"count": len(expired_subscriptions)}
)
except Exception as e:
logger.error(f"Ошибка проверки истекших подписок: {e}")
logger.error(f"Ошибка проверки истёкших подписок: {e}")
async def update_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
) -> Optional[RemnaWaveUser]:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user or not user.remnawave_uuid:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
current_time = datetime.utcnow()
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
await db.commit()
is_active = False
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
async with self.api as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
active_internal_squads=subscription.connected_squads
)
subscription.subscription_url = updated_user.subscription_url
await db.commit()
status_text = "активным" if is_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
async def _check_expiring_subscriptions(self, db: AsyncSession):
try:
@@ -188,7 +241,8 @@ class MonitoringService:
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
threshold_date = datetime.utcnow() + timedelta(days=days_before)
current_time = datetime.utcnow()
threshold_date = current_time + timedelta(days=days_before)
result = await db.execute(
select(Subscription)
@@ -197,12 +251,20 @@ class MonitoringService:
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == False,
Subscription.end_date <= threshold_date,
Subscription.end_date > datetime.utcnow()
Subscription.end_date > current_time,
Subscription.end_date <= threshold_date
)
)
)
return result.scalars().all()
logger.info(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
logger.info(f"📅 Текущее время: {current_time}")
logger.info(f"📅 Пороговая дата: {threshold_date}")
subscriptions = result.scalars().all()
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
return subscriptions
async def _process_autopayments(self, db: AsyncSession):
try:
@@ -307,7 +369,10 @@ class MonitoringService:
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 = "✅ Включен - подписка продлится автоматически"
@@ -317,7 +382,7 @@ class MonitoringService:
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
message = f"""
<b>Подписка истекает через {days} дней!</b>
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
+241 -5
View File
@@ -1,11 +1,18 @@
import logging
import hashlib
import hmac
from typing import Optional
from typing import Optional, Dict, Any
from datetime import datetime
from aiogram import Bot
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.yookassa_service import YooKassaService
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
from app.database.crud.transaction import create_transaction
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.models import TransactionType, PaymentMethod
logger = logging.getLogger(__name__)
@@ -14,6 +21,7 @@ class PaymentService:
def __init__(self, bot: Optional[Bot] = None):
self.bot = bot
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
async def create_stars_invoice(
self,
@@ -44,6 +52,237 @@ class PaymentService:
logger.error(f"Ошибка создания Stars invoice: {e}")
raise
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,
@@ -104,12 +343,9 @@ class PaymentService:
) -> bool:
try:
# Здесь должна быть логика обработки платежа
# Например, пополнение баланса пользователя
logger.info(f"Обработан успешный платеж: {payment_id}, {amount_kopeks/100}₽, {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка обработки платежа: {e}")
return False
return False
+36 -9
View File
@@ -86,32 +86,59 @@ class PromoCodeService:
effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}")
if promocode.subscription_days > 0:
from app.database.crud.subscription import create_paid_subscription
from app.config import settings
subscription = await get_subscription_by_user_id(db, user.id)
if subscription:
await extend_subscription(db, subscription, promocode.subscription_days)
await self.subscription_service.update_remnawave_user(db, subscription)
effects.append(f"⏰ Подписка продлена на {promocode.subscription_days} дней")
logger.info(f"✅ Подписка пользователя {user.telegram_id} продлена на {promocode.subscription_days} дней в RemnaWave с текущими сквадами")
else:
await create_paid_subscription(
from app.database.crud.subscription import create_paid_subscription
trial_squads = []
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
trial_squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=promocode.subscription_days,
traffic_limit_gb=0,
traffic_limit_gb=0,
device_limit=1,
connected_squads=[]
connected_squads=trial_squads
)
await self.subscription_service.create_remnawave_user(db, new_subscription)
effects.append(f"🎉 Получена подписка на {promocode.subscription_days} дней")
logger.info(f"✅ Создана новая подписка для пользователя {user.telegram_id} на {promocode.subscription_days} дней с триал сквадом {trial_squads}")
if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value:
from app.database.crud.subscription import create_trial_subscription
from app.config import settings
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription and not user.has_had_paid_subscription:
await create_trial_subscription(db, user.id)
effects.append("🎁 Активирована тестовая подписка")
if not subscription:
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
trial_subscription = await create_trial_subscription(
db,
user.id,
duration_days=trial_days
)
await self.subscription_service.create_remnawave_user(db, trial_subscription)
effects.append(f"🎁 Активирована тестовая подписка на {trial_days} дней")
logger.info(f"✅ Создана триал подписка для пользователя {user.telegram_id} на {trial_days} дней")
else:
effects.append("Тестовая подписка уже недоступна")
effects.append("У вас уже есть активная подписка")
return "\n".join(effects) if effects else "✅ Промокод активирован"
return "\n".join(effects) if effects else "✅ Промокод активирован"
+21 -9
View File
@@ -20,7 +20,7 @@ async def process_referral_registration(
referrer = await get_user_by_id(db, referrer_id)
if not new_user or not referrer:
logger.error(f"Пользователи не найдены: {new_user_id}, {referrer_id}")
logger.error(f"Пользователи не найдены: new_user_id={new_user_id}, referrer_id={referrer_id}")
return False
if new_user.referred_by_id != referrer_id:
@@ -81,9 +81,13 @@ async def process_referral_purchase(
logger.info(f"🔍 Покупка реферала {user_id}: первая = {is_first_purchase}, сумма = {purchase_amount_kopeks/100}")
if is_first_purchase:
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}"
@@ -100,12 +104,18 @@ async def process_referral_purchase(
logger.info(f"🎉 Первая покупка реферала: {referrer.telegram_id} получил {reward_amount/100}")
commission_amount = int(purchase_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 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"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с покупки {user.full_name}"
f"Комиссия {commission_percent}% с покупки {user.full_name}"
)
await create_referral_earning(
@@ -128,6 +138,8 @@ async def process_referral_purchase(
except Exception as e:
logger.error(f"Ошибка обработки покупки реферала: {e}")
import traceback
logger.error(f"Полный traceback: {traceback.format_exc()}")
return False
@@ -141,7 +153,7 @@ async def get_referral_stats_for_user(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()
invited_count = invited_count_result.scalar() or 0
paid_referrals_result = await db.execute(
select(func.count(User.id)).where(
@@ -149,13 +161,13 @@ async def get_referral_stats_for_user(db: AsyncSession, user_id: int) -> dict:
User.has_had_paid_subscription == True
)
)
paid_referrals_count = paid_referrals_result.scalar()
paid_referrals_count = paid_referrals_result.scalar() or 0
total_earned = await get_referral_earnings_sum(db, user_id)
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)
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago) or 0
return {
"invited_count": invited_count,
@@ -171,4 +183,4 @@ async def get_referral_stats_for_user(db: AsyncSession, user_id: int) -> dict:
"paid_referrals_count": 0,
"total_earned_kopeks": 0,
"month_earned_kopeks": 0
}
}
+296 -145
View File
@@ -1,6 +1,9 @@
import logging
from typing import Dict, List, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from datetime import datetime, timedelta
import re
from app.config import settings
from app.external.remnawave_api import (
@@ -9,7 +12,10 @@ from app.external.remnawave_api import (
)
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
from app.database.crud.subscription import get_subscription_by_user_id, update_subscription_usage
from app.database.models import User
from app.database.models import (
User, SubscriptionServer, Transaction, ReferralEarning,
PromoCodeUse, SubscriptionStatus
)
logger = logging.getLogger(__name__)
@@ -21,6 +27,34 @@ class RemnaWaveService:
base_url=settings.REMNAWAVE_API_URL,
api_key=settings.REMNAWAVE_API_KEY
)
def _parse_remnawave_date(self, date_str: str) -> datetime:
if not date_str:
return datetime.utcnow() + timedelta(days=30)
try:
cleaned_date = date_str.strip()
if cleaned_date.endswith('Z'):
cleaned_date = cleaned_date[:-1] + '+00:00'
if '+00:00+00:00' in cleaned_date:
cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00')
cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date)
parsed_date = datetime.fromisoformat(cleaned_date)
if parsed_date.tzinfo is not None:
parsed_date = parsed_date.replace(tzinfo=None)
logger.debug(f"Успешно распарсена дата: {date_str} -> {parsed_date}")
return parsed_date
except Exception as e:
logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.")
return datetime.utcnow() + timedelta(days=30)
async def get_system_statistics(self) -> Dict[str, Any]:
try:
@@ -54,7 +88,6 @@ class RemnaWaveService:
logger.error(f"Ошибка получения статистики нод: {e}")
nodes_stats = {}
from datetime import datetime
total_download = sum(node.get('downloadBytes', 0) for node in realtime_usage)
total_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage)
@@ -195,7 +228,6 @@ class RemnaWaveService:
'GBPS': 1024 ** 3
}
import re
match = re.match(r'([0-9.,]+)([A-Z]+)', bandwidth_str)
if match:
value_str = match.group(1).replace(',', '.')
@@ -391,108 +423,174 @@ class RemnaWaveService:
return False
async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]:
"""
Синхронизация пользователей из панели RemnaWave в бота
sync_type:
- "all": полная синхронизация (создание + обновление + удаление)
- "new_only": только создание новых пользователей
- "update_only": только обновление существующих
"""
try:
stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0}
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
async with self.api as api:
# Получаем всех пользователей из панели
panel_users_data = await api._make_request('GET', '/api/users')
panel_users = panel_users_data['response']['users']
logger.info(f"👥 Найдено пользователей в панели: {len(panel_users)}")
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
async with self.api as api:
panel_users = []
start = 0
size = 100
# Получаем всех пользователей из бота для сравнения
bot_users = await get_users_list(db, offset=0, limit=10000)
bot_users_by_telegram_id = {user.telegram_id: user for user in bot_users}
# Множество telegram_id из панели для проверки удаленных
panel_telegram_ids = set()
# Обрабатываем каждого пользователя из панели
for i, panel_user in enumerate(panel_users):
try:
telegram_id = panel_user.get('telegramId')
if not telegram_id:
logger.debug(f"➡️ Пропускаем пользователя без telegram_id")
continue
panel_telegram_ids.add(telegram_id)
logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(panel_users)}: {telegram_id}")
while True:
logger.info(f"📥 Загружаем пользователей: start={start}, size={size}")
db_user = bot_users_by_telegram_id.get(telegram_id)
response = await api.get_all_users(start=start, size=size)
users_batch = response['users']
total_users = response['total']
if not db_user:
# Пользователя нет в боте - создаем
if sync_type in ["new_only", "all"]:
logger.info(f"🆕 Создание пользователя для telegram_id {telegram_id}")
from app.database.crud.user import create_user
logger.info(f"📊 Получено {len(users_batch)} пользователей из {total_users}")
for user_obj in users_batch:
user_dict = {
'uuid': user_obj.uuid,
'shortUuid': user_obj.short_uuid,
'username': user_obj.username,
'status': user_obj.status.value,
'telegramId': user_obj.telegram_id,
'expireAt': user_obj.expire_at.isoformat() + 'Z',
'trafficLimitBytes': user_obj.traffic_limit_bytes,
'usedTrafficBytes': user_obj.used_traffic_bytes,
'hwidDeviceLimit': user_obj.hwid_device_limit,
'subscriptionUrl': user_obj.subscription_url,
'activeInternalSquads': user_obj.active_internal_squads
}
panel_users.append(user_dict)
if len(users_batch) < size:
break
start += size
if start > total_users:
break
logger.info(f"✅ Всего загружено пользователей из панели: {len(panel_users)}")
bot_users = await get_users_list(db, offset=0, limit=10000)
bot_users_by_telegram_id = {user.telegram_id: user for user in bot_users}
logger.info(f"📊 Пользователей в боте: {len(bot_users)}")
panel_users_with_tg = [
user for user in panel_users
if user.get('telegramId') is not None
]
logger.info(f"📊 Пользователей в панели с Telegram ID: {len(panel_users_with_tg)}")
panel_telegram_ids = set()
for i, panel_user in enumerate(panel_users_with_tg):
try:
telegram_id = panel_user.get('telegramId')
if not telegram_id:
continue
panel_telegram_ids.add(telegram_id)
if (i + 1) % 10 == 0:
logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(panel_users_with_tg)}: {telegram_id}")
db_user = bot_users_by_telegram_id.get(telegram_id)
if not db_user:
if sync_type in ["new_only", "all"]:
logger.info(f"🆕 Создание пользователя для telegram_id {telegram_id}")
db_user = await create_user(
db=db,
telegram_id=telegram_id,
username=panel_user.get('username') or f"user_{telegram_id}",
first_name=f"Panel User {telegram_id}",
language="ru"
)
from app.database.crud.user import create_user
db_user = await create_user(
db=db,
telegram_id=telegram_id,
username=panel_user.get('username') or f"user_{telegram_id}",
first_name=f"Panel User {telegram_id}",
language="ru"
)
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
await self._create_subscription_from_panel_data(db, db_user, panel_user)
stats["created"] += 1
logger.info(f"✅ Создан пользователь {telegram_id} с подпиской")
else:
if sync_type in ["update_only", "all"]:
logger.debug(f"🔄 Обновление пользователя {telegram_id}")
if not db_user.remnawave_uuid:
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
await self._create_subscription_from_panel_data(db, db_user, panel_user)
await self._update_subscription_from_panel_data(db, db_user, panel_user)
stats["created"] += 1
logger.info(f"Создан пользователь {telegram_id} с подпиской")
stats["updated"] += 1
logger.debug(f"Обновлён пользователь {telegram_id}")
else:
# Пользователь есть в боте - обновляем
if sync_type in ["update_only", "all"]:
logger.debug(f"🔄 Обновление пользователя {telegram_id}")
if not db_user.remnawave_uuid:
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
await self._update_subscription_from_panel_data(db, db_user, panel_user)
stats["updated"] += 1
logger.debug(f"✅ Обновлён пользователь {telegram_id}")
except Exception as user_error:
logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}")
stats["errors"] += 1
continue
except Exception as user_error:
logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}")
stats["errors"] += 1
continue
if sync_type == "all":
logger.info("🗑️ Деактивация подписок пользователей, отсутствующих в панели...")
# Удаляем подписки пользователей, которых нет в панели
if sync_type == "all":
logger.info("🗑️ Удаляем подписки пользователей, отсутствующих в панели...")
for telegram_id, db_user in bot_users_by_telegram_id.items():
if telegram_id not in panel_telegram_ids and db_user.subscription:
for telegram_id, db_user in bot_users_by_telegram_id.items():
if telegram_id not in panel_telegram_ids and db_user.subscription:
try:
logger.info(f"🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)")
subscription = db_user.subscription
if db_user.remnawave_uuid:
try:
async with self.api as api:
devices_reset = await api.reset_user_devices(db_user.remnawave_uuid)
if devices_reset:
logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}")
except Exception as hwid_error:
logger.error(f"❌ Ошибка сброса HWID устройств для {telegram_id}: {hwid_error}")
try:
logger.info(f"🗑️ Удаляем подписку пользователя {telegram_id} (нет в панели)")
from sqlalchemy import delete
from app.database.models import SubscriptionServer
# Деактивируем подписку
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, db_user.subscription)
stats["deleted"] += 1
logger.info(f"✅ Деактивирована подписка пользователя {telegram_id}")
except Exception as delete_error:
logger.error(f"❌ Ошибка удаления подписки {telegram_id}: {delete_error}")
stats["errors"] += 1
logger.info(f"🎯 Синхронизация завершена: создано {stats['created']}, обновлено {stats['updated']}, удалено {stats['deleted']}, ошибок {stats['errors']}")
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == subscription.id
)
)
logger.info(f"🗑️ Удалены серверы подписки для {telegram_id}")
except Exception as servers_error:
logger.warning(f"⚠️ Не удалось удалить серверы подписки: {servers_error}")
from app.database.models import SubscriptionStatus
subscription.status = SubscriptionStatus.DISABLED.value
subscription.is_trial = True
subscription.end_date = datetime.utcnow()
subscription.traffic_limit_gb = 0
subscription.traffic_used_gb = 0.0
subscription.device_limit = 1
subscription.connected_squads = []
subscription.autopay_enabled = False
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
db_user.remnawave_uuid = None
await db.commit()
stats["deleted"] += 1
logger.info(f"✅ Деактивирована подписка пользователя {telegram_id} (сохранен баланс)")
except Exception as delete_error:
logger.error(f"❌ Ошибка деактивации подписки {telegram_id}: {delete_error}")
stats["errors"] += 1
await db.rollback()
logger.info(f"🎯 Синхронизация завершена: создано {stats['created']}, обновлено {stats['updated']}, деактивировано {stats['deleted']}, ошибок {stats['errors']}")
return stats
except Exception as e:
@@ -503,25 +601,9 @@ class RemnaWaveService:
try:
from app.database.crud.subscription import create_subscription
from app.database.models import SubscriptionStatus
from datetime import datetime, timedelta
import pytz
expire_at_str = panel_user.get('expireAt', '')
try:
if expire_at_str:
if expire_at_str.endswith('Z'):
expire_at_str = expire_at_str[:-1] + '+00:00'
expire_at = datetime.fromisoformat(expire_at_str)
if expire_at.tzinfo is not None:
expire_at = expire_at.replace(tzinfo=None)
else:
expire_at = datetime.utcnow() + timedelta(days=30)
except Exception as date_error:
logger.warning(f"⚠️ Ошибка парсинга даты {expire_at_str}: {date_error}")
expire_at = datetime.utcnow() + timedelta(days=30)
expire_at = self._parse_remnawave_date(expire_at_str)
panel_status = panel_user.get('status', 'ACTIVE')
current_time = datetime.utcnow()
@@ -591,7 +673,6 @@ class RemnaWaveService:
try:
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import SubscriptionStatus
from datetime import datetime, timedelta
subscription = await get_subscription_by_user_id(db, user.id)
@@ -599,26 +680,16 @@ class RemnaWaveService:
await self._create_subscription_from_panel_data(db, user, panel_user)
return
# Обновляем статус подписки
panel_status = panel_user.get('status', 'ACTIVE')
expire_at_str = panel_user.get('expireAt', '')
try:
if expire_at_str:
if expire_at_str.endswith('Z'):
expire_at_str = expire_at_str[:-1] + '+00:00'
expire_at = datetime.fromisoformat(expire_at_str)
if expire_at.tzinfo is not None:
expire_at = expire_at.replace(tzinfo=None)
# Обновляем дату окончания если она отличается
if abs((subscription.end_date - expire_at).total_seconds()) > 60: # больше минуты разницы
subscription.end_date = expire_at
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
except Exception as date_error:
logger.warning(f"⚠️ Ошибка парсинга даты при обновлении {expire_at_str}: {date_error}")
if expire_at_str:
expire_at = self._parse_remnawave_date(expire_at_str)
if abs((subscription.end_date - expire_at).total_seconds()) > 60:
subscription.end_date = expire_at
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
# Обновляем статус
current_time = datetime.utcnow()
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
new_status = SubscriptionStatus.ACTIVE.value
@@ -627,13 +698,12 @@ class RemnaWaveService:
elif panel_status == 'DISABLED':
new_status = SubscriptionStatus.DISABLED.value
else:
new_status = subscription.status # Оставляем текущий статус
new_status = subscription.status
if subscription.status != new_status:
subscription.status = new_status
logger.debug(f"Обновлен статус подписки: {new_status}")
# Обновляем использованный трафик
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
traffic_used_gb = used_traffic_bytes / (1024**3)
@@ -641,7 +711,6 @@ class RemnaWaveService:
subscription.traffic_used_gb = traffic_used_gb
logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB")
# Обновляем лимит трафика
traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0)
traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0
@@ -649,22 +718,18 @@ class RemnaWaveService:
subscription.traffic_limit_gb = traffic_limit_gb
logger.debug(f"Обновлен лимит трафика: {traffic_limit_gb} GB")
# Обновляем лимит устройств
device_limit = panel_user.get('hwidDeviceLimit', 1) or 1
if subscription.device_limit != device_limit:
subscription.device_limit = device_limit
logger.debug(f"Обновлен лимит устройств: {device_limit}")
# Обновляем RemnaWave UUID если отсутствует
if not subscription.remnawave_short_uuid:
subscription.remnawave_short_uuid = panel_user.get('shortUuid')
# Обновляем URL подписки если отсутствует или изменился
panel_url = panel_user.get('subscriptionUrl', '')
if not subscription.subscription_url or subscription.subscription_url != panel_url:
subscription.subscription_url = panel_url
# Обновляем подключенные сквады
active_squads = panel_user.get('activeInternalSquads', [])
squad_uuids = []
if isinstance(active_squads, list):
@@ -674,7 +739,6 @@ class RemnaWaveService:
elif isinstance(squad, str):
squad_uuids.append(squad)
# Сравниваем сквады - обновляем только если есть изменения
current_squads = set(subscription.connected_squads or [])
new_squads = set(squad_uuids)
@@ -729,7 +793,6 @@ class RemnaWaveService:
active_internal_squads=subscription.connected_squads
)
# Обновляем UUID в нашей базе
await update_user(db, user, remnawave_uuid=new_user.uuid)
subscription.remnawave_short_uuid = new_user.short_uuid
await db.commit()
@@ -931,7 +994,6 @@ class RemnaWaveService:
node_realtime = stats
break
from datetime import datetime, timedelta
end_date = datetime.now()
start_date = end_date - timedelta(days=7)
@@ -969,11 +1031,100 @@ class RemnaWaveService:
logger.error(f"Ошибка валидации данных пользователя: {e}")
return False
async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool:
try:
logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}")
if user.remnawave_uuid:
try:
async with self.api as api:
devices_reset = await api.reset_user_devices(user.remnawave_uuid)
if devices_reset:
logger.info(f"🔧 Сброшены HWID устройства для {user.telegram_id}")
except Exception as hwid_error:
logger.warning(f"⚠️ Ошибка сброса HWID устройств: {hwid_error}")
try:
from sqlalchemy import delete
from app.database.models import (
SubscriptionServer, Transaction, ReferralEarning,
PromoCodeUse, SubscriptionStatus
)
if user.subscription:
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
)
)
logger.info(f"🗑️ Удалены серверы подписки для {user.telegram_id}")
await db.execute(
delete(Transaction).where(Transaction.user_id == user.id)
)
logger.info(f"🗑️ Удалены транзакции для {user.telegram_id}")
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
)
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
)
logger.info(f"🗑️ Удалены реферальные доходы для {user.telegram_id}")
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
)
logger.info(f"🗑️ Удалены использования промокодов для {user.telegram_id}")
except Exception as records_error:
logger.error(f"❌ Ошибка удаления связанных записей: {records_error}")
try:
user.balance_kopeks = 0
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.used_promocodes = 0
user.updated_at = datetime.utcnow()
if user.subscription:
user.subscription.status = SubscriptionStatus.DISABLED.value
user.subscription.is_trial = True
user.subscription.end_date = datetime.utcnow()
user.subscription.traffic_limit_gb = 0
user.subscription.traffic_used_gb = 0.0
user.subscription.device_limit = 1
user.subscription.connected_squads = []
user.subscription.autopay_enabled = False
user.subscription.autopay_days_before = 3
user.subscription.remnawave_short_uuid = None
user.subscription.subscription_url = ""
user.subscription.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ ПРИНУДИТЕЛЬНО очищены ВСЕ данные пользователя {user.telegram_id}")
return True
except Exception as cleanup_error:
logger.error(f"❌ Ошибка финальной очистки пользователя: {cleanup_error}")
await db.rollback()
return False
except Exception as e:
logger.error(f"❌ Критическая ошибка принудительной очистки пользователя {user.telegram_id}: {e}")
await db.rollback()
return False
async def cleanup_orphaned_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
"""
Усиленная версия очистки с полным удалением данных
"""
try:
stats = {"deactivated": 0, "errors": 0, "checked": 0}
logger.info("🧹 Начинаем очистку неактуальных подписок...")
logger.info("🧹 Начинаем усиленную очистку неактуальных подписок...")
async with self.api as api:
panel_users_data = await api._make_request('GET', '/api/users')
@@ -1008,12 +1159,14 @@ class RemnaWaveService:
continue
if user.telegram_id not in panel_telegram_ids:
logger.info(f"🗑️ Деактивируем подписку пользователя {user.telegram_id} (отсутствует в панели)")
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, subscription)
stats["deactivated"] += 1
logger.info(f"🗑️ ПОЛНАЯ деактивация подписки пользователя {user.telegram_id} (отсутствует в панели)")
cleanup_success = await self.force_cleanup_user_data(db, user)
if cleanup_success:
stats["deactivated"] += 1
else:
stats["errors"] += 1
except Exception as sub_error:
logger.error(f"❌ Ошибка обработки подписки {subscription.id}: {sub_error}")
@@ -1023,11 +1176,11 @@ class RemnaWaveService:
if len(subscriptions) < limit:
break
logger.info(f"🧹 Очистка завершена: проверено {stats['checked']}, деактивировано {stats['deactivated']}, ошибок {stats['errors']}")
logger.info(f"🧹 Усиленная очистка завершена: проверено {stats['checked']}, деактивировано {stats['deactivated']}, ошибок {stats['errors']}")
return stats
except Exception as e:
logger.error(f"❌ Критическая ошибка очистки подписок: {e}")
logger.error(f"❌ Критическая ошибка усиленной очистки подписок: {e}")
return {"deactivated": 0, "errors": 1, "checked": 0}
@@ -1051,7 +1204,6 @@ class RemnaWaveService:
from app.database.crud.subscription import get_all_subscriptions
from app.database.models import SubscriptionStatus
from datetime import datetime
page = 1
limit = 100
@@ -1104,7 +1256,6 @@ class RemnaWaveService:
from app.database.crud.subscription import get_all_subscriptions
from app.database.models import SubscriptionStatus
from datetime import datetime
page = 1
limit = 100
+82 -5
View File
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import Subscription, User
from app.database.models import Subscription, User, SubscriptionStatus
from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, UserStatus,
TrafficLimitStrategy, RemnaWaveAPIError
@@ -34,10 +34,23 @@ class SubscriptionService:
logger.error(f"Пользователь {subscription.user_id} не найден")
return None
validation_success = await self.validate_and_clean_subscription(db, subscription, user)
if not validation_success:
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
return None
async with self.api as api:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
remnawave_user = existing_users[0]
try:
await api.reset_user_devices(remnawave_user.uuid)
logger.info(f"🔧 Сброшены HWID устройства для пользователя {user.telegram_id}")
except Exception as hwid_error:
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
updated_user = await api.update_user(
uuid=remnawave_user.uuid,
status=UserStatus.ACTIVE,
@@ -47,7 +60,9 @@ class SubscriptionService:
hwid_device_limit=subscription.device_limit,
active_internal_squads=subscription.connected_squads
)
else:
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
username = f"user_{user.telegram_id}"
updated_user = await api.create_user(
username=username,
@@ -67,7 +82,7 @@ class SubscriptionService:
await db.commit()
logger.info(f"✅ Создан RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
return updated_user
@@ -91,10 +106,23 @@ class SubscriptionService:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
current_time = datetime.utcnow()
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
await db.commit()
is_actually_active = False
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
async with self.api as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
@@ -105,12 +133,13 @@ class SubscriptionService:
subscription.subscription_url = updated_user.subscription_url
await db.commit()
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid}")
status_text = "активным" if is_actually_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
logger.error(f"Ошибка RemnaWave API: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
@@ -267,6 +296,54 @@ class SubscriptionService:
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,
+80 -15
View File
@@ -48,7 +48,6 @@ class TributeService:
if not payment_url:
return None
return payment_url
except Exception as e:
@@ -91,19 +90,28 @@ class TributeService:
return {"status": "ok", "event": event_type}
async def _handle_successful_payment(self, payment_data: Dict[str, Any]):
"""Обработка успешного платежа - ПОЛНОСТЬЮ ПЕРЕРАБОТАННАЯ ВЕРСИЯ"""
try:
user_id = payment_data["user_id"]
amount_kopeks = payment_data["amount_kopeks"]
payment_id = payment_data["payment_id"]
logger.info(f"Обрабатываем успешный Tribute платеж: user_id={user_id}, amount={amount_kopeks}, payment_id={payment_id}")
async for session in get_db():
existing_transaction = await get_transaction_by_external_id(
session, f"donation_{payment_id}", PaymentMethod.TRIBUTE
from app.database.crud.transaction import check_tribute_payment_duplicate, create_unique_tribute_transaction
duplicate_transaction = await check_tribute_payment_duplicate(
session, payment_id, amount_kopeks, user_id
)
if existing_transaction:
logger.warning(f"Транзакция с donation_request_id {payment_id} уже существует")
if duplicate_transaction:
logger.warning(f"Найден дубликат платежа:")
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
logger.warning(f" Created: {duplicate_transaction.created_at}")
logger.warning(f"Платеж игнорирован")
return
user = await get_user_by_telegram_id(session, user_id)
@@ -111,29 +119,34 @@ class TributeService:
logger.error(f"Пользователь {user_id} не найден")
return
transaction = await create_transaction(
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
transaction = await create_unique_tribute_transaction(
db=session,
user_id=user.id,
type=TransactionType.DEPOSIT,
user_id=user.id,
payment_id=payment_id,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Tribute: {amount_kopeks/100}",
payment_method=PaymentMethod.TRIBUTE,
external_id=f"donation_{payment_id}",
is_completed=True
description=f"Пополнение через Tribute: {amount_kopeks/100} (ID: {payment_id})"
)
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
await session.commit()
logger.info(f"Баланс пользователя {user_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
await self._send_success_notification(user_id, amount_kopeks)
logger.info(f"Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_id}")
break
except Exception as e:
logger.error(f"Ошибка обработки успешного Tribute платежа: {e}")
logger.error(f"Ошибка обработки успешного Tribute платежа: {e}", exc_info=True)
async def _handle_failed_payment(self, payment_data: Dict[str, Any]):
"""Обработка неудачного платежа"""
try:
user_id = payment_data["user_id"]
@@ -141,7 +154,7 @@ class TributeService:
async for session in get_db():
transaction = await get_transaction_by_external_id(
session, payment_id, PaymentMethod.TRIBUTE
session, f"donation_{payment_id}", PaymentMethod.TRIBUTE
)
if transaction:
@@ -157,6 +170,7 @@ class TributeService:
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
async def _handle_refund(self, refund_data: Dict[str, Any]):
"""Обработка возврата"""
try:
user_id = refund_data["user_id"]
@@ -189,6 +203,7 @@ class TributeService:
logger.error(f"Ошибка обработки возврата Tribute: {e}")
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
"""Отправка уведомления об успешном платеже"""
try:
amount_rubles = amount_kopeks / 100
@@ -217,6 +232,7 @@ class TributeService:
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
async def _send_failure_notification(self, user_id: int):
"""Отправка уведомления о неудачном платеже"""
try:
text = (
@@ -245,6 +261,7 @@ class TributeService:
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
"""Отправка уведомления о возврате"""
try:
amount_rubles = amount_kopeks / 100
@@ -272,6 +289,54 @@ class TributeService:
except Exception as e:
logger.error(f"Ошибка отправки уведомления о возврате: {e}")
async def force_process_payment(
self,
payment_id: str,
user_id: int,
amount_kopeks: int,
description: str = "Принудительная обработка Tribute платежа"
) -> bool:
"""Принудительная обработка платежа (для отладки)"""
try:
logger.info(f"🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}")
async for session in get_db():
user = await get_user_by_telegram_id(session, user_id)
if not user:
logger.error(f"❌ Пользователь {user_id} не найден")
return False
external_id = f"force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}"
transaction = await create_transaction(
db=session,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description,
payment_method=PaymentMethod.TRIBUTE,
external_id=external_id,
is_completed=True
)
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
await session.commit()
logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп")
await self._send_success_notification(user_id, amount_kopeks)
logger.info(f"✅ Принудительно обработан платеж {payment_id}")
return True
except Exception as e:
logger.error(f"❌ Ошибка принудительной обработки: {e}", exc_info=True)
return False
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
return await self.tribute_api.get_payment_status(payment_id)
@@ -281,4 +346,4 @@ class TributeService:
amount_kopeks: Optional[int] = None,
reason: str = "Возврат по запросу"
) -> Optional[Dict[str, Any]]:
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
+2 -3
View File
@@ -135,16 +135,15 @@ class UserService:
return False
if amount_kopeks > 0:
await add_user_balance(db, user, amount_kopeks, description)
await add_user_balance(db, user, amount_kopeks, description=description)
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}")
return True
else:
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
if success:
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}")
return success
return True
except Exception as e:
logger.error(f"Ошибка изменения баланса пользователя: {e}")
return False
+194
View File
@@ -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
+4 -1
View File
@@ -35,6 +35,9 @@ class AdminStates(StatesGroup):
waiting_for_user_search = State()
editing_user_balance = State()
extending_subscription = State()
adding_traffic = State()
granting_subscription = State()
editing_user_subscription = State()
creating_promocode = State()
@@ -79,4 +82,4 @@ class SquadCreateStates(StatesGroup):
selecting_inbounds = State()
class SquadRenameStates(StatesGroup):
waiting_for_new_name = State()
waiting_for_new_name = State()
+12 -1
View File
@@ -67,6 +67,17 @@ def format_time_ago(dt: Union[datetime, str]) -> str:
else:
return "только что"
def format_days_declension(days: int, language: str = "ru") -> str:
if language != "ru":
return f"{days} day{'s' if days != 1 else ''}"
if days % 10 == 1 and days % 100 != 11:
return f"{days} день"
elif days % 10 in [2, 3, 4] and days % 100 not in [12, 13, 14]:
return f"{days} дня"
else:
return f"{days} дней"
def format_duration(seconds: int) -> str:
if seconds < 60:
@@ -204,4 +215,4 @@ def format_boolean(value: bool, language: str = "ru") -> str:
if language == "ru":
return "✅ Да" if value else "❌ Нет"
else:
return "✅ Yes" if value else "❌ No"
return "✅ Yes" if value else "❌ No"
+97
View File
@@ -0,0 +1,97 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, User
logger = logging.getLogger(__name__)
async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Optional[Subscription]:
result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
)
subscriptions = result.scalars().all()
if len(subscriptions) <= 1:
return subscriptions[0] if subscriptions else None
latest_subscription = subscriptions[0]
old_subscriptions = subscriptions[1:]
logger.warning(f"🚨 Обнаружено {len(subscriptions)} подписок у пользователя {user_id}. Удаляем {len(old_subscriptions)} старых.")
for old_sub in old_subscriptions:
await db.delete(old_sub)
logger.info(f"🗑️ Удалена подписка ID {old_sub.id} от {old_sub.created_at}")
await db.commit()
await db.refresh(latest_subscription)
logger.info(f"✅ Оставлена подписка ID {latest_subscription.id} от {latest_subscription.created_at}")
return latest_subscription
async def update_or_create_subscription(
db: AsyncSession,
user_id: int,
**subscription_data
) -> Subscription:
existing_subscription = await ensure_single_subscription(db, user_id)
if existing_subscription:
for key, value in subscription_data.items():
if hasattr(existing_subscription, key):
setattr(existing_subscription, key, value)
existing_subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing_subscription)
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
return existing_subscription
else:
new_subscription = Subscription(
user_id=user_id,
**subscription_data
)
db.add(new_subscription)
await db.commit()
await db.refresh(new_subscription)
logger.info(f"🆕 Создана новая подписка ID {new_subscription.id}")
return new_subscription
async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int:
result = await db.execute(
select(Subscription.user_id)
.group_by(Subscription.user_id)
.having(func.count(Subscription.id) > 1)
)
users_with_duplicates = result.scalars().all()
total_deleted = 0
for user_id in users_with_duplicates:
subscriptions_result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
)
subscriptions = subscriptions_result.scalars().all()
for old_subscription in subscriptions[1:]:
await db.delete(old_subscription)
total_deleted += 1
logger.info(f"🗑️ Удалена дублирующаяся подписка ID {old_subscription.id} пользователя {user_id}")
await db.commit()
logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок")
return total_deleted
+77
View File
@@ -0,0 +1,77 @@
services:
postgres:
image: postgres:15-alpine
container_name: remnawave_bot_db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7-alpine
container_name: remnawave_bot_redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
networks:
- bot_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
bot:
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
container_name: remnawave_bot
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- .env
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-remnawave_user}:${POSTGRES_PASSWORD:-secure_password_123}@postgres:5432/${POSTGRES_DB:-remnawave_bot}
REDIS_URL: redis://redis:6379/0
volumes:
- ./logs:/app/logs:rw
- ./data:/app/data:rw
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
networks:
- bot_network
user: "1000:1000"
command: >
bash -c "
mkdir -p /app/logs /app/data &&
python main.py
"
volumes:
postgres_data:
redis_data:
networks:
bot_network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
+153 -16
View File
@@ -1,6 +1,8 @@
import asyncio
import logging
import sys
import os
import signal
from pathlib import Path
sys.path.append(str(Path(__file__).parent))
@@ -9,7 +11,21 @@ from app.bot import setup_bot
from app.config import settings
from app.database.database import init_db
from app.services.monitoring_service import monitoring_service
from app.services.maintenance_service import maintenance_service
from app.services.payment_service import PaymentService
from app.external.webhook_server import WebhookServer
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.database.universal_migration import run_universal_migration
class GracefulExit:
def __init__(self):
self.exit = False
def exit_gracefully(self, signum, frame):
logging.getLogger(__name__).info(f"Получен сигнал {signum}. Корректное завершение работы...")
self.exit = True
async def main():
@@ -23,52 +39,173 @@ async def main():
)
logger = logging.getLogger(__name__)
logger.info("🚀 Запуск VPN бота...")
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
killer = GracefulExit()
signal.signal(signal.SIGINT, killer.exit_gracefully)
signal.signal(signal.SIGTERM, killer.exit_gracefully)
webhook_server = None
yookassa_server_task = None
monitoring_task = None
maintenance_task = None
polling_task = None
try:
logger.info("📊 Инициализация базы данных...")
await init_db()
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
if not skip_migration:
logger.info("🔧 Выполняем проверку и миграцию базы данных...")
try:
migration_success = await run_universal_migration()
if migration_success:
logger.info("✅ Миграция базы данных завершена успешно")
else:
logger.warning("⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск")
except Exception as migration_error:
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
logger.warning("⚠️ Продолжаем запуск без миграции")
else:
logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)")
logger.info("🤖 Настройка бота...")
bot, dp = await setup_bot()
monitoring_service.bot = bot
maintenance_service.set_bot(bot)
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
payment_service = PaymentService(bot)
# Инициализируем webhook сервер если Tribute включен
if settings.TRIBUTE_ENABLED:
logger.info("🌐 Запуск webhook сервера для Tribute...")
logger.info("🌐 Запуск Tribute webhook сервера...")
webhook_server = WebhookServer(bot)
await webhook_server.start()
else:
logger.info("ℹ️ Tribute отключен, webhook сервер не запускается")
logger.info("🔍 Запуск службы мониторинга...")
if settings.is_yookassa_enabled():
logger.info("💳 Запуск YooKassa webhook сервера...")
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
else:
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
logger.info("📊 Запуск службы мониторинга...")
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
logger.info("🔧 Проверка службы техработ...")
if not maintenance_service._check_task or maintenance_service._check_task.done():
logger.info("🔧 Запуск службы техработ...")
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
else:
logger.info("🔧 Служба техработ уже запущена")
maintenance_task = None
logger.info("🔄 Запуск polling...")
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
logger.info("=" * 50)
logger.info("🎯 Активные webhook endpoints:")
if settings.TRIBUTE_ENABLED:
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_yookassa_enabled():
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
logger.info("=" * 50)
try:
await asyncio.gather(
dp.start_polling(bot),
monitoring_task
)
while not killer.exit:
await asyncio.sleep(1)
if yookassa_server_task and yookassa_server_task.done():
exception = yookassa_server_task.exception()
if exception:
logger.error(f"YooKassa webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск YooKassa webhook сервера...")
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
if monitoring_task.done():
exception = monitoring_task.exception()
if exception:
logger.error(f"Служба мониторинга завершилась с ошибкой: {exception}")
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
if maintenance_task and maintenance_task.done():
exception = maintenance_task.exception()
if exception:
logger.error(f"Служба техработ завершилась с ошибкой: {exception}")
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
if polling_task.done():
exception = polling_task.exception()
if exception:
logger.error(f"Polling завершился с ошибкой: {exception}")
break
except Exception as e:
logger.error(f"Ошибка в основном цикле: {e}")
monitoring_service.stop_monitoring()
if webhook_server:
await webhook_server.stop()
raise
except Exception as e:
logger.error(f"❌ Критическая ошибка при запуске: {e}")
raise
finally:
logger.info("🛑 Завершение работы бота")
monitoring_service.stop_monitoring()
logger.info("🛑 Начинается корректное завершение работы...")
if yookassa_server_task and not yookassa_server_task.done():
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
yookassa_server_task.cancel()
try:
await yookassa_server_task
except asyncio.CancelledError:
pass
if monitoring_task and not monitoring_task.done():
logger.info("ℹ️ Остановка службы мониторинга...")
monitoring_service.stop_monitoring()
monitoring_task.cancel()
try:
await monitoring_task
except asyncio.CancelledError:
pass
if maintenance_task and not maintenance_task.done():
logger.info("ℹ️ Остановка службы техработ...")
await maintenance_service.stop_monitoring()
maintenance_task.cancel()
try:
await maintenance_task
except asyncio.CancelledError:
pass
if polling_task and not polling_task.done():
logger.info("ℹ️ Остановка polling...")
polling_task.cancel()
try:
await polling_task
except asyncio.CancelledError:
pass
if webhook_server:
logger.info("ℹ️ Остановка Tribute webhook сервера...")
await webhook_server.stop()
if 'bot' in locals():
try:
await bot.session.close()
logger.info("✅ Сессия бота закрыта")
except Exception as e:
logger.error(f"Ошибка закрытия сессии бота: {e}")
logger.info("✅ Завершение работы бота завершено")
if __name__ == "__main__":
@@ -78,4 +215,4 @@ if __name__ == "__main__":
print("\n🛑 Бот остановлен пользователем")
except Exception as e:
print(f"❌ Критическая ошибка: {e}")
sys.exit(1)
sys.exit(1)
@@ -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 ###
+6
View File
@@ -12,9 +12,15 @@ pydantic-settings==2.1.0
python-dotenv==1.0.0
redis==5.0.1
# YooKassa SDK
yookassa==3.0.0
# Логирование и мониторинг
structlog==23.2.0
# Планировщик задач для техработ
APScheduler==3.10.4
# Утилиты
python-dateutil==2.8.2
pytz==2023.4
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB