Compare commits

...

167 Commits

Author SHA1 Message Date
Egor fcf84aa41e Merge pull request #2397 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-23 03:46:28 +03:00
Egor db01725582 Update subscription.py 2026-01-23 03:45:50 +03:00
Egor 9269770703 Merge pull request #2396 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-23 00:36:42 +03:00
Egor e91cc23156 Update subscription.py 2026-01-23 00:36:25 +03:00
Egor ffc9453b76 Merge pull request #2395 from BEDOLAGA-DEV/dev5
Update purchase.py
2026-01-22 23:25:04 +03:00
Egor 3a9404c349 Update purchase.py 2026-01-22 23:23:25 +03:00
Egor de2f3de28a Merge pull request #2394 from BEDOLAGA-DEV/dev5
Update admin_promo_offers.py
2026-01-22 23:08:48 +03:00
Egor 25318c1c41 Update admin_promo_offers.py 2026-01-22 23:08:19 +03:00
Egor c233ba8a8c Merge pull request #2393 from BEDOLAGA-DEV/dev5
Dev5
2026-01-22 22:39:19 +03:00
Egor 2a82b037d8 Add files via upload 2026-01-22 22:39:01 +03:00
Egor 67083980a3 Update payment_service.py 2026-01-22 22:38:31 +03:00
Egor 05f65af8e9 Merge pull request #2392 from BEDOLAGA-DEV/dev5
Dev5
2026-01-22 22:34:24 +03:00
Egor 085459dfd3 Update transaction.py 2026-01-22 22:34:09 +03:00
Egor 65af46cdae Update reporting_service.py 2026-01-22 22:33:40 +03:00
Egor f218852f5f Merge pull request #2391 from BEDOLAGA-DEV/main
w
2026-01-22 22:33:00 +03:00
Egor 6635666112 Merge pull request #2390 from Gy9vin/main
fix(referral): исправить потерю реферальных кодов при обязательной по…
2026-01-22 22:00:27 +03:00
Egor 83f9d05fe3 Update payments.py 2026-01-22 21:59:10 +03:00
gy9vin d47a65c29f fix(referral): исправить потерю реферальных кодов при обязательной подписке на канал
Проблема: у некоторых пользователей реферальный код из deep link терялся,
  потому что pending_start_payload сохранялся только в FSM state, который
  мог быть недоступен (state=None) в edge cases.
                                                            Исправления:
  - Добавлен Redis fallback для хранения payload (TTL 1 час)
  - _capture_start_payload() теперь сохраняет в FSM state И в Redis
  - cmd_start() и required_sub_channel_check() проверяют Redis если FSM state
пуст
  - Добавлено логирование warning при state=None
  - Изменён уровень лога успешного сохранения с debug на info

  Изменённые файлы:
  - app/middlewares/channel_checker.py — Redis-функции и улучшенное логирование
  - app/handlers/start.py — Redis fallback в обработчиках

  Добавлены тесты:
  - tests/middlewares/test_channel_checker_payload.py (14 тестов)
2026-01-22 21:54:32 +03:00
Egor 626c67a7a7 Update balance.py 2026-01-22 21:52:43 +03:00
Egor e9c6ea9fc9 Update payments.py 2026-01-22 21:44:53 +03:00
Egor 318dda9e04 Update cloudpayments_service.py 2026-01-22 21:44:13 +03:00
Egor c73b0433b9 Merge pull request #2387 from BEDOLAGA-DEV/dev5
Dev5
2026-01-22 16:09:27 +03:00
Egor 0c2293fef2 Update remnawave_service.py 2026-01-22 16:08:55 +03:00
Egor 9f5971563b Merge pull request #2386 from BEDOLAGA-DEV/main
w
2026-01-22 16:06:54 +03:00
Egor 5930506972 Merge pull request #2379 from Gy9vin/main
feat(payments): добавить KassaAI как отдельную платёжную систему
2026-01-21 16:12:00 +03:00
Egor 6b6d79257e Merge pull request #2383 from BEDOLAGA-DEV/dev5
Dev5
2026-01-21 15:43:56 +03:00
Egor 86c2092eff Update subscription.py 2026-01-21 15:43:29 +03:00
Egor 7bd838f0b0 Update subscription_checker.py 2026-01-21 15:42:13 +03:00
Egor 5563314718 Add files via upload 2026-01-21 15:41:33 +03:00
Egor db69af159b Merge pull request #2382 from BEDOLAGA-DEV/main
w
2026-01-21 15:22:48 +03:00
Egor 4a16bcbccf Update auth.py 2026-01-21 15:03:15 +03:00
Egor 8dec623f2d Update README.md 2026-01-21 11:49:53 +03:00
Egor 0cb714b3a9 Update Dockerfile 2026-01-21 10:36:42 +03:00
Egor 2c3c4ba09c Update docker-registry.yml 2026-01-21 10:36:27 +03:00
Egor 48c6c8dd63 Update docker-hub.yml 2026-01-21 10:36:15 +03:00
Egor d01dd47d57 Merge pull request #2380 from BEDOLAGA-DEV/dev5
Update purchase.py
2026-01-21 10:13:19 +03:00
Egor d51d51db55 Update purchase.py 2026-01-21 10:09:27 +03:00
Mikhail 060ae9decf Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-21 09:49:12 +03:00
Egor 275c797566 Merge pull request #2378 from BEDOLAGA-DEV/dev5
Update balance.py
2026-01-21 09:32:05 +03:00
Egor d9a4af341e Update balance.py 2026-01-21 09:31:43 +03:00
Egor a4f337a502 Merge pull request #2377 from BEDOLAGA-DEV/dev5
Update yookassa.py
2026-01-21 09:27:40 +03:00
Egor 1f55d76459 Update yookassa.py 2026-01-21 09:27:17 +03:00
Egor 2760a744db Merge pull request #2376 from BEDOLAGA-DEV/dev5
Update promo.py
2026-01-21 08:21:17 +03:00
Egor f169c08275 Update promo.py 2026-01-21 08:21:02 +03:00
Egor c69b371c53 Merge pull request #2375 from BEDOLAGA-DEV/dev5
Update promo.py
2026-01-21 08:12:34 +03:00
Egor a45d667c89 Update promo.py 2026-01-21 08:12:17 +03:00
Egor 894e4e02b2 Merge pull request #2374 from BEDOLAGA-DEV/dev5
Dev5
2026-01-21 07:56:30 +03:00
Egor c289b96f1a Update subscription_purchase_service.py 2026-01-21 07:56:04 +03:00
Egor 6425cfb0fb Merge pull request #2373 from BEDOLAGA-DEV/main
ц
2026-01-21 07:35:10 +03:00
Egor a56daca368 Update balance.py 2026-01-21 07:34:25 +03:00
Egor d0628eebda Update config.py 2026-01-21 07:33:54 +03:00
Egor 6a36504699 Merge pull request #2372 from BEDOLAGA-DEV/dev5
Update wheel_service.py
2026-01-21 07:04:31 +03:00
Egor 8db061553f Update wheel_service.py 2026-01-21 07:03:07 +03:00
Egor 7eb8750d0f Merge pull request #2371 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-21 06:46:40 +03:00
Egor ae7f63aed0 Update subscription.py 2026-01-21 06:46:07 +03:00
Egor c18b4a3cbb Merge pull request #2370 from BEDOLAGA-DEV/dev5
Dev5
2026-01-21 05:55:28 +03:00
Egor c0cada8fb5 Add files via upload 2026-01-21 05:54:54 +03:00
Egor 1117a1dd34 Merge pull request #2369 from BEDOLAGA-DEV/main
w
2026-01-21 05:16:41 +03:00
Egor abd312dacc Merge pull request #2368 from Gy9vin/main
feat(monitoring): добавить настройки мониторинга трафика в админку
2026-01-21 05:14:36 +03:00
gy9vin 7aa64521d2 feat(payments): добавить KassaAI как отдельную платёжную систему
Новая платёжка KassaAI (api.fk.life) работает параллельно с Freekassa.

  Добавлено:
  - app/services/kassa_ai_service.py — API-сервис
  - app/database/crud/kassa_ai.py — CRUD-операции
  - app/services/payment/kassa_ai.py — KassaAiPaymentMixin
  - app/handlers/balance/kassa_ai.py — хендлеры пополнения

  Изменено:
  - config.py — настройки KASSA_AI_*
  - models.py — PaymentMethod.KASSA_AI, модель KassaAiPayment
  - payment_service.py — подключён KassaAiPaymentMixin
  - webserver/payments.py — webhook /kassa-ai-webhook
  - keyboards/inline.py — кнопка KassaAI
  - handlers/balance/main.py — регистрация хендлеров
  - universal_migration.py — миграция таблицы kassa_ai_payments
  - system_settings_service.py — настройки в админке
  - .env.example — примеры переменных

  Способы оплаты: 44=СБП, 36=Карты РФ, 43=SberPay
2026-01-20 19:09:27 +03:00
Mikhail b99ff79920 Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-20 17:20:46 +03:00
gy9vin dff723aede feat(monitoring): добавить настройки мониторинга трафика в админку
- Добавлена кнопка "⚙️ Настройки трафика" в меню мониторинга
  - Добавлен UI для управления быстрой и суточной проверками трафика
  - Можно включать/выключать проверки, менять пороги и интервалы
  - Настройки сохраняются в БД через BotConfigurationService
  - Добавлены SETTING_HINTS с описаниями параметров
2026-01-20 17:19:57 +03:00
Egor 86097b300e Merge pull request #2367 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 16:57:18 +03:00
Egor f6b795e555 Update miniapp.py 2026-01-20 16:56:59 +03:00
Egor 7719d035a1 Update dependencies.py 2026-01-20 16:56:07 +03:00
Egor 33e11fb25a Update database.py 2026-01-20 16:55:35 +03:00
Egor fcdda41541 Merge pull request #2366 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-20 16:44:59 +03:00
Egor c5183f5a9f Update subscription.py 2026-01-20 16:44:38 +03:00
Egor f200f90150 Merge pull request #2365 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 14:45:45 +03:00
Egor e42421d2ff Update dependencies.py 2026-01-20 14:28:35 +03:00
Egor c783884ace Update miniapp.py 2026-01-20 14:27:37 +03:00
Egor 6ca1bae6f8 Merge pull request #2364 from BEDOLAGA-DEV/dev5
Update freekassa_service.py
2026-01-20 13:18:45 +03:00
Egor e1aeff55d7 Update freekassa_service.py 2026-01-20 13:18:14 +03:00
Egor 10107964ba Merge pull request #2363 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 13:06:16 +03:00
Egor ade2794d52 Update start.py 2026-01-20 13:05:45 +03:00
Egor a462657f96 Merge pull request #2362 from BEDOLAGA-DEV/main
ц
2026-01-20 13:04:40 +03:00
Egor 91af4a3818 Update Dockerfile 2026-01-20 08:04:44 +03:00
Egor 05f29c25ec Update docker-registry.yml 2026-01-20 08:04:30 +03:00
Egor 5b851f1047 Update docker-hub.yml 2026-01-20 08:04:08 +03:00
Egor fac258a40d Merge pull request #2361 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-20 06:37:30 +03:00
Egor d92071e8aa Update subscription.py 2026-01-20 06:37:04 +03:00
Egor f6c9304a40 Merge pull request #2360 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 06:23:22 +03:00
Egor b994a5ffd6 Add files via upload 2026-01-20 06:23:00 +03:00
Egor 0413a0ee6a Update purchase.py 2026-01-20 06:22:24 +03:00
Egor 1fc8d38c70 Merge pull request #2359 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-20 05:39:59 +03:00
Egor f50fbe232d Update subscription.py 2026-01-20 05:39:36 +03:00
Egor 80e1b68863 Merge pull request #2358 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 02:35:07 +03:00
Egor 4f54ffcc2a Update subscription.py 2026-01-20 02:34:35 +03:00
Egor 6fed2542eb Merge pull request #2357 from BEDOLAGA-DEV/main
w
2026-01-20 02:33:50 +03:00
Egor c331c33321 Merge pull request #2356 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 02:20:14 +03:00
PEDZEO 338ee832ea Add fullscreen settings endpoints and models to branding.py 2026-01-20 02:17:35 +03:00
Egor b7a05f7cd1 Update miniapp.py 2026-01-20 02:14:42 +03:00
Egor dd396aa16d Update miniapp.py 2026-01-20 02:14:10 +03:00
Egor 814d224359 Update subscription.py 2026-01-20 02:13:34 +03:00
Egor 95ab0236d9 Update subscription.py 2026-01-20 02:13:01 +03:00
Egor 4fbe07919c Merge pull request #2355 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 01:46:57 +03:00
Egor 1752f52197 Update miniapp.py 2026-01-20 01:39:50 +03:00
Egor 5fe37e57a6 Update subscription.py 2026-01-20 01:39:09 +03:00
PEDZEO da9997609b Add custom logging configuration to WebAPIServer to reduce WebSocket spam 2026-01-20 01:30:17 +03:00
Egor 92b7c0b602 Merge pull request #2354 from BEDOLAGA-DEV/main
ц
2026-01-20 01:24:16 +03:00
Egor 44b3a0dfab Merge pull request #2353 from BEDOLAGA-DEV/dev5
Update subscription_auto_purchase_service.py
2026-01-20 01:23:34 +03:00
Egor b3bbd48723 Update subscription_auto_purchase_service.py 2026-01-20 01:23:06 +03:00
Egor e51afc6bcf Merge pull request #2352 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 01:02:27 +03:00
Egor 0ae70d702e Update purchase.py 2026-01-20 01:00:43 +03:00
Egor 722b800f1d Update subscription_purchase_service.py 2026-01-20 00:59:55 +03:00
Egor cd9287236e Update subscription.py 2026-01-20 00:58:58 +03:00
Egor 18e8e38855 Update miniapp.py 2026-01-20 00:58:24 +03:00
Egor a63d587aad Update subscription_utils.py 2026-01-20 00:54:45 +03:00
Egor aba2df927d Update tariff_purchase.py 2026-01-20 00:53:56 +03:00
Egor d78f85735b Update remnawave_api.py 2026-01-20 00:52:59 +03:00
PEDZEO a7e0f52e27 Merge pull request #2351 from BEDOLAGA-DEV/test 2026-01-20 00:41:18 +03:00
Egor b9ca73a306 Update subscription.py 2026-01-20 00:41:09 +03:00
Egor c26ccb8e96 Update subscription_purchase_service.py 2026-01-20 00:40:22 +03:00
PEDZEO 38bf8633b7 Merge branch 'test' of https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot into test 2026-01-20 00:38:29 +03:00
PEDZEO 73d52f2047 Adjust logging levels for WebSocket connections to reduce verbosity 2026-01-20 00:38:23 +03:00
Egor d781290652 Merge pull request #2350 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 00:28:33 +03:00
Egor 9d453283ec Update global_error.py 2026-01-20 00:28:16 +03:00
Egor 98d01cff49 Update message_patch.py 2026-01-20 00:27:50 +03:00
Egor 255c623143 Merge pull request #2349 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 00:20:20 +03:00
Egor 2d69d98d81 Update subscription_renewal_service.py 2026-01-20 00:19:26 +03:00
Egor 8ad2d50b25 Add files via upload 2026-01-20 00:18:57 +03:00
Egor 372654683a Merge pull request #2348 from BEDOLAGA-DEV/main
w
2026-01-20 00:17:42 +03:00
Egor 6fd470a02f Merge pull request #2347 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 23:58:03 +03:00
Egor cb4f79dd6e Update monitoring_service.py 2026-01-19 23:57:45 +03:00
Egor b38e06383d Update user.py 2026-01-19 23:57:03 +03:00
Egor 5523c6bf76 Merge pull request #2346 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 23:47:01 +03:00
Egor 57981b69c7 Update server_squad.py 2026-01-19 23:46:41 +03:00
PEDZEO 6a3e716ad2 Refactor logging levels in websocket.py files to use debug instead of info and warning for connection attempts and token validation. 2026-01-19 23:46:03 +03:00
Egor c21804f8db Add files via upload 2026-01-19 23:45:53 +03:00
Egor 3937bbffc9 Merge pull request #2345 from BEDOLAGA-DEV/dev5
Update auth.py
2026-01-19 23:36:06 +03:00
Egor 70441da7de Update auth.py 2026-01-19 23:35:12 +03:00
Egor df2626adae Merge pull request #2344 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-19 23:17:18 +03:00
Egor 9bffeb4151 Update subscription.py 2026-01-19 23:17:00 +03:00
Egor 8524e2c023 Merge pull request #2343 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 23:06:38 +03:00
Egor 3553ff615d Update subscription.py 2026-01-19 23:06:21 +03:00
Egor ca0682e48f Merge pull request #2342 from BEDOLAGA-DEV/main
w
2026-01-19 22:52:58 +03:00
Egor 1e1c0e89bc Update subscription.py 2026-01-19 22:51:57 +03:00
Egor 93227b7f41 Update yookassa.py 2026-01-19 22:49:58 +03:00
Egor 0a51e4d9ea Merge pull request #2339 from BEDOLAGA-DEV/dev5
Update branding.py
2026-01-19 10:22:50 +03:00
Egor 9e69b0cf22 Update branding.py 2026-01-19 10:17:33 +03:00
Egor aaaaa25231 Merge pull request #2338 from BEDOLAGA-DEV/dev5
Update admin_promocodes.py
2026-01-19 09:32:03 +03:00
Egor 10fea329ad Update admin_promocodes.py 2026-01-19 09:31:41 +03:00
Egor 408c5c520e Merge pull request #2337 from BEDOLAGA-DEV/dev5
Update admin_stats.py
2026-01-19 09:17:15 +03:00
Egor 6775b06b2e Update admin_stats.py 2026-01-19 08:51:48 +03:00
Egor 9460c86f7a Merge pull request #2336 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 07:53:16 +03:00
Egor f8d7b3288c Update cloudpayments.py 2026-01-19 07:52:54 +03:00
Egor bd6498fb73 Update subscription.py 2026-01-19 07:52:16 +03:00
Egor cdb3507a56 Update tariff_purchase.py 2026-01-19 06:26:15 +03:00
Egor 0b18c16f47 Update subscription_auto_purchase_service.py 2026-01-19 06:25:30 +03:00
Egor f452434b13 Merge pull request #2335 from BEDOLAGA-DEV/main
w
2026-01-19 06:24:22 +03:00
PEDZEO e6e688a395 fix(routes): reorder notification and ticket routers to prevent route conflicts
- Moved the notifications router to be included before the tickets router to avoid conflicts.
- Updated comments for clarity regarding the order of router inclusion.
2026-01-19 01:30:31 +03:00
PEDZEO b1206a84c7 feat(notifications): enhance notification security and ownership checks
- Added ownership verification for user notifications to ensure only the rightful owner can mark them as read.
- Implemented checks to confirm that admin notifications are correctly identified before allowing them to be marked as read.
- Introduced a new method to retrieve notifications by ID in the TicketNotificationCRUD for improved data handling.
2026-01-19 00:39:36 +03:00
PEDZEO 63e45e12de Merge pull request #2334 from BEDOLAGA-DEV/test
feat(notifications): implement ticket notifications for users and admins
2026-01-19 00:34:03 +03:00
PEDZEO 792ff22471 Merge branch 'main' into test 2026-01-19 00:33:21 +03:00
PEDZEO 346806bce0 feat(notifications): integrate WebSocket notifications for ticket replies and new tickets
- Added WebSocket notifications for admins on new ticket creation and user replies.
- Implemented notification handling in the ticket management routes.
- Enhanced error logging for notification failures.
2026-01-19 00:28:57 +03:00
PEDZEO 5630e99812 Merge branch 'main' of https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot 2026-01-19 00:03:43 +03:00
PEDZEO 67c3dba1cc feat(notifications): implement ticket notifications for users and admins
- Added a new TicketNotification model to handle notifications for ticket events.
- Implemented user and admin notifications for new tickets and replies in the cabinet.
- Introduced settings to enable or disable notifications for users and admins.
- Enhanced ticket settings to include notification preferences.
- Integrated WebSocket notifications for real-time updates.
2026-01-19 00:02:41 +03:00
Egor c1e901ba5e Merge pull request #2333 from BEDOLAGA-DEV/dev5
Update auth.py
2026-01-18 23:34:02 +03:00
Egor b6f2052464 Update auth.py 2026-01-18 23:33:45 +03:00
PEDZEO c63db708cc feat(tickets): notify admins on new ticket creation and replies
- Added functionality to notify admins when a new ticket is created.
- Implemented notification for admins when a user replies to a ticket.
- Included error handling for notification failures.
2026-01-18 23:23:46 +03:00
Egor 3fc702ec65 Merge pull request #2332 from BEDOLAGA-DEV/dev5
Dev5
2026-01-18 22:15:37 +03:00
Egor 0aff94de74 Update main.py 2026-01-18 22:15:03 +03:00
Egor d8c60ce19f Merge pull request #2331 from BEDOLAGA-DEV/main
w
2026-01-18 22:14:34 +03:00
Egor f3995c7ca8 Merge pull request #2330 from DrillUser/patch-1
+убрать очепятку
2026-01-18 21:31:22 +03:00
Egor 1330f394bb Merge pull request #2328 from Gy9vin/main
fix(contests): исправлены критические баги системы конкурсов
2026-01-18 21:31:01 +03:00
Vladislav ec27d63f70 +убрать очепятку 2026-01-18 14:09:04 +03:00
gy9vin 78d785f83f fix(contests): исправлены критические баги системы конкурсов
- Исправлен вызов get_active_rounds в админ-панели (передавалось 2 параметра вместо 1)
- Обновлены кнопки редактирования призов с prize_days на prize_type/prize_value
- Мигрирован Cabinet API с устаревшего prize_days на новые поля
- Добавлена поддержка нескольких типов призов (дни, баланс, кастом)
- Обновлена документация API конкурсов
2026-01-18 10:15:45 +03:00
80 changed files with 6675 additions and 418 deletions
+18 -1
View File
@@ -214,7 +214,7 @@ TRIAL_ACTIVATION_PRICE=0
# Сколько устройств доступно по дефолту при покупке платной подписки
DEFAULT_DEVICE_LIMIT=3
# Максимум устройств достопных к покупке (0 = Нет лимита)
# Максимум устройств доступных к покупке (0 = Нет лимита)
MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
@@ -581,6 +581,23 @@ FREEKASSA_PAYMENT_SYSTEM_ID=
# Использовать API для создания заказов (обязательно для NSPK СБП)
FREEKASSA_USE_API=false
# ===== KASSA AI (api.fk.life) =====
# Отдельная платёжная система, работает параллельно с Freekassa
KASSA_AI_ENABLED=false
KASSA_AI_SHOP_ID=
KASSA_AI_API_KEY=
# Секретное слово 2 (для webhook)
KASSA_AI_SECRET_WORD_2=
KASSA_AI_DISPLAY_NAME=KassaAI
KASSA_AI_CURRENCY=RUB
KASSA_AI_MIN_AMOUNT_KOPEKS=10000
KASSA_AI_MAX_AMOUNT_KOPEKS=100000000
KASSA_AI_WEBHOOK_PATH=/kassa-ai-webhook
KASSA_AI_WEBHOOK_HOST=0.0.0.0
KASSA_AI_WEBHOOK_PORT=8089
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID=44
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.1.0-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.1.0-dev-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v3.1.0-pr-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.1.0-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.1.0-dev-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v3.1.0-pr-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.1.0"
ARG VERSION="v3.1.2"
ARG BUILD_DATE
ARG VCS_REF
+1 -1
View File
@@ -1,6 +1,6 @@
# 🚀 Remnawave Bedolaga Bot
> **🆕 Новый веб-кабинет (Cabinet WebApp)**
> **🆕 Новый веб-кабинет (Cabinet WebApp) https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/**
>
> Вышла новая версия личного кабинета пользователя — веб-интерфейс для управления подписками!
>
+61
View File
@@ -1,18 +1,34 @@
"""FastAPI dependencies for cabinet module."""
import logging
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from aiogram import Bot
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.database.crud.user import get_user_by_id
from app.config import settings
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Optional[Bot] = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
@@ -40,6 +56,11 @@ async def get_current_cabinet_user(
Raises:
HTTPException: If token is invalid, expired, or user not found
"""
# Check maintenance mode first (except for admins - checked later)
if maintenance_service.is_maintenance_active():
# We need to check token first to see if user is admin
pass # Will check after getting user
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -80,6 +101,46 @@ async def get_current_cabinet_user(
detail="User account is not active",
)
# Check maintenance mode (allow admins to pass)
if maintenance_service.is_maintenance_active():
if not settings.is_admin(user.telegram_id):
status_info = maintenance_service.get_status_info()
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "maintenance",
"message": maintenance_service.get_maintenance_message() or "Service is under maintenance",
"reason": status_info.get("reason"),
},
)
# Check required channel subscription
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Skip check for admins
if not settings.is_admin(user.telegram_id):
try:
bot = _get_channel_check_bot()
chat_member = await bot.get_chat_member(
chat_id=settings.CHANNEL_SUB_ID,
user_id=user.telegram_id
)
# Не закрываем сессию - бот переиспользуется
if chat_member.status not in ["member", "administrator", "creator"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "channel_subscription_required",
"message": "Please subscribe to our channel to continue",
"channel_link": settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Failed to check channel subscription for user {user.telegram_id}: {e}")
# Don't block user if check fails
return user
+10 -1
View File
@@ -7,6 +7,8 @@ from .subscription import router as subscription_router
from .balance import router as balance_router
from .referral import router as referral_router
from .tickets import router as tickets_router
from .ticket_notifications import router as ticket_notifications_router
from .ticket_notifications import admin_router as admin_ticket_notifications_router
from .admin_tickets import router as admin_tickets_router
from .admin_settings import router as admin_settings_router
from .admin_apps import router as admin_apps_router
@@ -32,6 +34,7 @@ from .admin_payments import router as admin_payments_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_remnawave import router as admin_remnawave_router
from .media import router as media_router
from .websocket import router as websocket_router
# Main cabinet router
router = APIRouter(prefix="/cabinet", tags=["Cabinet"])
@@ -41,6 +44,8 @@ router.include_router(auth_router)
router.include_router(subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
# Notifications router MUST be before tickets router to avoid route conflict
router.include_router(ticket_notifications_router)
router.include_router(tickets_router)
router.include_router(promocode_router)
router.include_router(contests_router)
@@ -54,7 +59,8 @@ router.include_router(media_router)
# Wheel routes
router.include_router(wheel_router)
# Admin routes
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
@@ -72,4 +78,7 @@ router.include_router(admin_payments_router)
router.include_router(admin_promo_offers_router)
router.include_router(admin_remnawave_router)
# WebSocket route
router.include_router(websocket_router)
__all__ = ["router"]
+162 -2
View File
@@ -2,13 +2,21 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.discount_offer import (
count_discount_offers,
list_discount_offers,
@@ -24,9 +32,12 @@ from app.database.crud.promo_offer_template import (
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, User
from app.handlers.admin.messages import get_custom_users, get_target_users
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/promo-offers", tags=["Admin Promo Offers"])
@@ -110,12 +121,18 @@ class PromoOfferBroadcastRequest(BaseModel):
target: Optional[str] = None
user_id: Optional[int] = None
telegram_id: Optional[int] = None
# Telegram notification options
send_notification: bool = Field(False, description="Send Telegram notification to users")
message_text: Optional[str] = Field(None, description="Custom message text (HTML)")
button_text: Optional[str] = Field(None, description="Button text")
class PromoOfferBroadcastResponse(BaseModel):
created_offers: int
user_ids: List[int]
target: Optional[str] = None
notifications_sent: int = 0
notifications_failed: int = 0
class PromoOfferLogOfferInfo(BaseModel):
@@ -344,13 +361,137 @@ async def list_offers(
)
def _get_bot() -> Bot:
"""Create bot instance for sending notifications."""
return Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
def _build_default_promo_message(
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
) -> str:
"""Build default promo notification message."""
lines = ["🎁 <b>Специальное предложение для вас!</b>\n"]
if discount_percent > 0:
lines.append(f"🔥 Скидка <b>{discount_percent}%</b> на подписку")
if bonus_amount_kopeks > 0:
bonus_rub = bonus_amount_kopeks / 100
lines.append(f"💰 Бонус <b>{bonus_rub:.0f}₽</b> на баланс")
lines.append(f"\n⏰ Предложение действует <b>{valid_hours} ч.</b>")
lines.append("\nНажмите кнопку ниже, чтобы активировать!")
return "\n".join(lines)
async def _send_promo_notifications(
offers_to_notify: List[tuple[User, DiscountOffer]],
message_text: Optional[str],
button_text: Optional[str],
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
) -> tuple[int, int]:
"""Send Telegram notifications for promo offers.
Returns:
Tuple of (sent_count, failed_count)
"""
if not offers_to_notify:
return 0, 0
bot = _get_bot()
sent = 0
failed = 0
# Build message text
text = message_text or _build_default_promo_message(
discount_percent=discount_percent,
bonus_amount_kopeks=bonus_amount_kopeks,
valid_hours=valid_hours,
)
# Default button text
btn_text = button_text or "🎁 Получить"
semaphore = asyncio.Semaphore(20)
async def send_single(user: User, offer: DiscountOffer) -> bool:
async with semaphore:
try:
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
build_miniapp_or_callback_button(
text=btn_text,
callback_data=f"claim_discount_{offer.id}",
)
],
[
InlineKeyboardButton(
text="❌ Закрыть",
callback_data="promo_offer_close",
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning(
"Failed to send promo notification to user %s: %s",
user.telegram_id,
exc,
)
return False
except Exception as exc:
logger.error(
"Error sending promo notification to user %s: %s",
user.telegram_id,
exc,
)
return False
# Send in batches
batch_size = 50
for i in range(0, len(offers_to_notify), batch_size):
batch = offers_to_notify[i : i + batch_size]
tasks = [send_single(user, offer) for user, offer in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, bool) and result:
sent += 1
else:
failed += 1
# Small delay between batches
if i + batch_size < len(offers_to_notify):
await asyncio.sleep(0.1)
# Close bot session
await bot.session.close()
return sent, failed
@router.post("/broadcast", response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def broadcast_offer(
payload: PromoOfferBroadcastRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferBroadcastResponse:
"""Broadcast promo offer to users."""
"""Broadcast promo offer to users with optional Telegram notification."""
recipients: dict[int, User] = {}
# Resolve target segment
@@ -386,8 +527,10 @@ async def broadcast_offer(
"No recipients: specify target or user",
)
# Create offers for all recipients
# Create offers for all recipients and collect (user, offer) pairs
created_offers = 0
offers_to_notify: List[tuple[User, DiscountOffer]] = []
for recipient in recipients.values():
offer = await upsert_discount_offer(
db,
@@ -402,11 +545,28 @@ async def broadcast_offer(
)
if offer:
created_offers += 1
offers_to_notify.append((recipient, offer))
# Send Telegram notifications if requested
notifications_sent = 0
notifications_failed = 0
if payload.send_notification and offers_to_notify:
notifications_sent, notifications_failed = await _send_promo_notifications(
offers_to_notify=offers_to_notify,
message_text=payload.message_text,
button_text=payload.button_text,
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
valid_hours=payload.valid_hours,
)
return PromoOfferBroadcastResponse(
created_offers=created_offers,
user_ids=list(recipients.keys()),
target=payload.target,
notifications_sent=notifications_sent,
notifications_failed=notifications_failed,
)
+24
View File
@@ -249,6 +249,18 @@ def _validate_create_payload(payload: PromoCodeCreateRequest) -> None:
"Subscription days must be positive for this promo code type"
)
if payload.type == PromoCodeType.DISCOUNT:
if payload.balance_bonus_kopeks <= 0 or payload.balance_bonus_kopeks > 100:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Discount percent must be between 1 and 100"
)
if payload.subscription_days <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Discount validity hours must be positive"
)
if normalized_valid_from and normalized_valid_until and normalized_valid_from > normalized_valid_until:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
@@ -289,6 +301,18 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
"Subscription days must be positive for this promo code type"
)
if new_type == PromoCodeType.DISCOUNT:
if balance_bonus <= 0 or balance_bonus > 100:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Discount percent must be between 1 and 100"
)
if subscription_days <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Discount validity hours must be positive"
)
valid_from = (
_normalize_datetime(payload.valid_from)
if payload.valid_from is not None
+552 -2
View File
@@ -13,11 +13,14 @@ from sqlalchemy import select, func, and_
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period
from app.database.crud.server_squad import get_server_statistics
from app.database.crud.campaign import get_campaigns_list, get_campaign_statistics, get_campaigns_count
from app.services.remnawave_service import RemnaWaveService
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_admin_user
from app.database.models import User, Subscription, Tariff, SubscriptionStatus
from app.database.models import (
User, Subscription, Tariff, SubscriptionStatus,
Transaction, TransactionType, ReferralEarning,
)
logger = logging.getLogger(__name__)
@@ -36,6 +39,15 @@ class NodeStatus(BaseModel):
users_online: int
traffic_used_bytes: Optional[int] = None
uptime: Optional[str] = None
xray_version: Optional[str] = None
node_version: Optional[str] = None
last_status_message: Optional[str] = None
xray_uptime: Optional[str] = None
is_xray_running: Optional[bool] = None
cpu_count: Optional[int] = None
cpu_model: Optional[str] = None
total_ram: Optional[str] = None
country_code: Optional[str] = None
class NodesOverview(BaseModel):
@@ -116,6 +128,81 @@ class DashboardStats(BaseModel):
tariff_stats: Optional[TariffStats] = None
# ============ Extended Stats Schemas ============
class TopReferrerItem(BaseModel):
"""Single referrer in top list."""
user_id: int
telegram_id: int
username: Optional[str] = None
display_name: str
invited_count: int
invited_today: int = 0
invited_week: int = 0
invited_month: int = 0
earnings_today_kopeks: int = 0
earnings_week_kopeks: int = 0
earnings_month_kopeks: int = 0
earnings_total_kopeks: int = 0
class TopReferrersResponse(BaseModel):
"""Top referrers response."""
by_earnings: List[TopReferrerItem]
by_invited: List[TopReferrerItem]
total_referrers: int
total_referrals: int
total_earnings_kopeks: int
class TopCampaignItem(BaseModel):
"""Single campaign in top list."""
id: int
name: str
start_parameter: str
bonus_type: str
is_active: bool
registrations: int
conversions: int
conversion_rate: float
total_revenue_kopeks: int
avg_revenue_per_user_kopeks: int
created_at: Optional[str] = None
class TopCampaignsResponse(BaseModel):
"""Top campaigns response."""
campaigns: List[TopCampaignItem]
total_campaigns: int
total_registrations: int
total_revenue_kopeks: int
class RecentPaymentItem(BaseModel):
"""Single recent payment."""
id: int
user_id: int
telegram_id: int
username: Optional[str] = None
display_name: str
amount_kopeks: int
amount_rubles: float
type: str
type_display: str
payment_method: Optional[str] = None
description: Optional[str] = None
created_at: str
is_completed: bool
class RecentPaymentsResponse(BaseModel):
"""Recent payments response."""
payments: List[RecentPaymentItem]
total_count: int
total_today_kopeks: int
total_week_kopeks: int
# ============ Routes ============
@router.get("/dashboard", response_model=DashboardStats)
@@ -300,6 +387,15 @@ async def _get_nodes_overview() -> NodesOverview:
users_online=n.get("users_online", 0) or 0,
traffic_used_bytes=n.get("traffic_used_bytes"),
uptime=n.get("uptime"),
xray_version=n.get("xray_version"),
node_version=n.get("node_version"),
last_status_message=n.get("last_status_message"),
xray_uptime=n.get("xray_uptime"),
is_xray_running=n.get("is_xray_running"),
cpu_count=n.get("cpu_count"),
cpu_model=n.get("cpu_model"),
total_ram=n.get("total_ram"),
country_code=n.get("country_code"),
)
for n in nodes
]
@@ -426,3 +522,457 @@ async def _get_tariff_stats(db: AsyncSession) -> Optional[TariffStats]:
except Exception as e:
logger.error(f"Failed to get tariff stats: {e}", exc_info=True)
return None
# ============ Extended Stats Routes ============
@router.get("/referrals/top", response_model=TopReferrersResponse)
async def get_top_referrers(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top referrers with earnings breakdown by period."""
try:
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
# Get all referrers with their stats
referrers_query = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('total_invited')
)
.where(User.referred_by_id.isnot(None))
.group_by(User.referred_by_id)
)
referrers_data = {row.referrer_id: {'total_invited': row.total_invited} for row in referrers_query}
# Get invited counts by period for each referrer
# Today
today_invited_query = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('count')
)
.where(
and_(
User.referred_by_id.isnot(None),
User.created_at >= today_start
)
)
.group_by(User.referred_by_id)
)
for row in today_invited_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['invited_today'] = row.count
# Week
week_invited_query = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('count')
)
.where(
and_(
User.referred_by_id.isnot(None),
User.created_at >= week_ago
)
)
.group_by(User.referred_by_id)
)
for row in week_invited_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['invited_week'] = row.count
# Month
month_invited_query = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('count')
)
.where(
and_(
User.referred_by_id.isnot(None),
User.created_at >= month_ago
)
)
.group_by(User.referred_by_id)
)
for row in month_invited_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['invited_month'] = row.count
# Get earnings from ReferralEarning table
# Total earnings
total_earnings_query = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('total')
)
.group_by(ReferralEarning.user_id)
)
for row in total_earnings_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_total'] = row.total or 0
# Today earnings
today_earnings_query = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('total')
)
.where(ReferralEarning.created_at >= today_start)
.group_by(ReferralEarning.user_id)
)
for row in today_earnings_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_today'] = row.total or 0
# Week earnings
week_earnings_query = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('total')
)
.where(ReferralEarning.created_at >= week_ago)
.group_by(ReferralEarning.user_id)
)
for row in week_earnings_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_week'] = row.total or 0
# Month earnings
month_earnings_query = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('total')
)
.where(ReferralEarning.created_at >= month_ago)
.group_by(ReferralEarning.user_id)
)
for row in month_earnings_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_month'] = row.total or 0
# Also add REFERRAL_REWARD transactions
trans_total_query = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('total')
)
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
.group_by(Transaction.user_id)
)
for row in trans_total_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_total'] = \
referrers_data[row.referrer_id].get('earnings_total', 0) + (row.total or 0)
trans_today_query = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('total')
)
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= today_start
)
)
.group_by(Transaction.user_id)
)
for row in trans_today_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_today'] = \
referrers_data[row.referrer_id].get('earnings_today', 0) + (row.total or 0)
trans_week_query = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('total')
)
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= week_ago
)
)
.group_by(Transaction.user_id)
)
for row in trans_week_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_week'] = \
referrers_data[row.referrer_id].get('earnings_week', 0) + (row.total or 0)
trans_month_query = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('total')
)
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= month_ago
)
)
.group_by(Transaction.user_id)
)
for row in trans_month_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_month'] = \
referrers_data[row.referrer_id].get('earnings_month', 0) + (row.total or 0)
# Get user info for all referrers
referrer_ids = list(referrers_data.keys())
if referrer_ids:
users_query = await db.execute(
select(User.id, User.telegram_id, User.username, User.first_name, User.last_name)
.where(User.id.in_(referrer_ids))
)
users_info = {u.id: u for u in users_query}
else:
users_info = {}
# Build referrer items
referrer_items = []
for referrer_id, data in referrers_data.items():
user = users_info.get(referrer_id)
if not user:
continue
display_name = ""
if user.first_name:
display_name = user.first_name
if user.last_name:
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
display_name = f"ID{user.telegram_id}"
referrer_items.append(TopReferrerItem(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
display_name=display_name,
invited_count=data.get('total_invited', 0),
invited_today=data.get('invited_today', 0),
invited_week=data.get('invited_week', 0),
invited_month=data.get('invited_month', 0),
earnings_today_kopeks=data.get('earnings_today', 0),
earnings_week_kopeks=data.get('earnings_week', 0),
earnings_month_kopeks=data.get('earnings_month', 0),
earnings_total_kopeks=data.get('earnings_total', 0),
))
# Sort by earnings and by invited
by_earnings = sorted(referrer_items, key=lambda x: x.earnings_total_kopeks, reverse=True)[:limit]
by_invited = sorted(referrer_items, key=lambda x: x.invited_count, reverse=True)[:limit]
# Calculate totals
total_referrers = len(referrer_items)
total_referrals = sum(r.invited_count for r in referrer_items)
total_earnings = sum(r.earnings_total_kopeks for r in referrer_items)
return TopReferrersResponse(
by_earnings=by_earnings,
by_invited=by_invited,
total_referrers=total_referrers,
total_referrals=total_referrals,
total_earnings_kopeks=total_earnings,
)
except Exception as e:
logger.error(f"Failed to get top referrers: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load referrers statistics",
)
@router.get("/campaigns/top", response_model=TopCampaignsResponse)
async def get_top_campaigns(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top advertising campaigns with statistics."""
try:
# Get all campaigns
campaigns = await get_campaigns_list(db, offset=0, limit=100, include_inactive=True)
campaign_items = []
total_registrations = 0
total_revenue = 0
for campaign in campaigns:
stats = await get_campaign_statistics(db, campaign.id)
campaign_items.append(TopCampaignItem(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
registrations=stats.get("registrations", 0),
conversions=stats.get("conversion_count", 0),
conversion_rate=stats.get("conversion_rate", 0.0),
total_revenue_kopeks=stats.get("total_revenue_kopeks", 0),
avg_revenue_per_user_kopeks=stats.get("avg_revenue_per_user_kopeks", 0),
created_at=campaign.created_at.isoformat() if campaign.created_at else None,
))
total_registrations += stats.get("registrations", 0)
total_revenue += stats.get("total_revenue_kopeks", 0)
# Sort by revenue
campaign_items.sort(key=lambda x: x.total_revenue_kopeks, reverse=True)
total_campaigns = await get_campaigns_count(db)
return TopCampaignsResponse(
campaigns=campaign_items[:limit],
total_campaigns=total_campaigns,
total_registrations=total_registrations,
total_revenue_kopeks=total_revenue,
)
except Exception as e:
logger.error(f"Failed to get top campaigns: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load campaigns statistics",
)
@router.get("/payments/recent", response_model=RecentPaymentsResponse)
async def get_recent_payments(
limit: int = 50,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get recent payments with user info."""
try:
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
# Get recent transactions (deposits and subscription payments)
transactions_query = await db.execute(
select(Transaction)
.where(
Transaction.type.in_([
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
])
)
.order_by(Transaction.created_at.desc())
.limit(limit)
)
transactions = transactions_query.scalars().all()
# Get user info for all transactions
user_ids = list(set(t.user_id for t in transactions))
if user_ids:
users_query = await db.execute(
select(User.id, User.telegram_id, User.username, User.first_name, User.last_name)
.where(User.id.in_(user_ids))
)
users_info = {u.id: u for u in users_query}
else:
users_info = {}
# Type display names
type_display = {
TransactionType.DEPOSIT.value: "Пополнение",
TransactionType.SUBSCRIPTION_PAYMENT.value: "Оплата подписки",
TransactionType.WITHDRAWAL.value: "Вывод",
TransactionType.REFUND.value: "Возврат",
TransactionType.REFERRAL_REWARD.value: "Реферальный бонус",
TransactionType.POLL_REWARD.value: "Награда за опрос",
}
payment_items = []
for trans in transactions:
user = users_info.get(trans.user_id)
if not user:
continue
display_name = ""
if user.first_name:
display_name = user.first_name
if user.last_name:
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
display_name = f"ID{user.telegram_id}"
payment_items.append(RecentPaymentItem(
id=trans.id,
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
display_name=display_name,
amount_kopeks=trans.amount_kopeks,
amount_rubles=trans.amount_kopeks / 100,
type=trans.type,
type_display=type_display.get(trans.type, trans.type),
payment_method=trans.payment_method,
description=trans.description,
created_at=trans.created_at.isoformat() if trans.created_at else "",
is_completed=trans.is_completed,
))
# Calculate totals
total_count_result = await db.execute(
select(func.count(Transaction.id))
.where(
Transaction.type.in_([
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
])
)
)
total_count = total_count_result.scalar() or 0
today_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= today_start
)
)
)
total_today = today_total_result.scalar() or 0
week_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= week_ago
)
)
)
total_week = week_total_result.scalar() or 0
return RecentPaymentsResponse(
payments=payment_items,
total_count=total_count,
total_today_kopeks=total_today,
total_week_kopeks=total_week,
)
except Exception as e:
logger.error(f"Failed to get recent payments: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load recent payments",
)
+32
View File
@@ -13,7 +13,9 @@ from pydantic import BaseModel, Field
from app.database.models import User, Ticket, TicketMessage
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.config import settings
from app.cabinet.routes.websocket import notify_user_ticket_reply
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tickets import TicketMessageResponse
@@ -110,6 +112,9 @@ class TicketSettingsResponse(BaseModel):
sla_check_interval_seconds: int
sla_reminder_cooldown_minutes: int
support_system_mode: str # tickets, contact, both
# Cabinet notifications settings
cabinet_user_notifications_enabled: bool = True
cabinet_admin_notifications_enabled: bool = True
class TicketSettingsUpdateRequest(BaseModel):
@@ -119,6 +124,9 @@ class TicketSettingsUpdateRequest(BaseModel):
sla_check_interval_seconds: Optional[int] = Field(None, ge=30, le=600, description="Check interval (30-600 seconds)")
sla_reminder_cooldown_minutes: Optional[int] = Field(None, ge=1, le=120, description="Reminder cooldown (1-120 minutes)")
support_system_mode: Optional[str] = Field(None, description="Support mode: tickets, contact, both")
# Cabinet notifications settings
cabinet_user_notifications_enabled: Optional[bool] = Field(None, description="Enable user notifications in cabinet")
cabinet_admin_notifications_enabled: Optional[bool] = Field(None, description="Enable admin notifications in cabinet")
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
@@ -348,6 +356,17 @@ async def reply_to_ticket(
except Exception as e:
logger.warning(f"Failed to send Telegram notification: {e}")
# Уведомить пользователя в кабинете
try:
notification = await TicketNotificationCRUD.create_user_notification_for_admin_reply(
db, ticket, request.message
)
if notification:
# Отправить WebSocket уведомление
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or "")[:100])
except Exception as e:
logger.warning(f"Failed to create cabinet notification for admin reply: {e}")
return _message_to_response(message)
@@ -475,12 +494,16 @@ async def get_ticket_settings(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
from app.services.support_settings_service import SupportSettingsService
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@@ -493,6 +516,7 @@ async def update_ticket_settings(
"""Update ticket system settings."""
import os
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
# Validate support_system_mode
if request.support_system_mode is not None:
@@ -515,6 +539,12 @@ async def update_ticket_settings(
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
if request.cabinet_admin_notifications_enabled is not None:
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
# Try to persist to .env file
try:
env_file = Path(".env")
@@ -563,4 +593,6 @@ async def update_ticket_settings(
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
+37 -9
View File
@@ -1,5 +1,6 @@
"""Authentication routes for cabinet."""
import asyncio
import hashlib
import logging
from datetime import datetime
@@ -94,6 +95,16 @@ async def _store_refresh_token(
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
# Check if token already exists (handles race conditions)
existing = await db.execute(
select(CabinetRefreshToken).where(
CabinetRefreshToken.token_hash == token_hash
)
)
if existing.scalar_one_or_none():
# Token already stored, skip
return
token_record = CabinetRefreshToken(
user_id=user_id,
token_hash=token_hash,
@@ -101,7 +112,11 @@ async def _store_refresh_token(
expires_at=expires_at,
)
db.add(token_record)
await db.commit()
try:
await db.commit()
except Exception:
# Handle race condition if token was inserted between check and insert
await db.rollback()
@router.post("/telegram", response_model=AuthResponse)
@@ -282,11 +297,12 @@ async def register_email(
await db.commit()
# Send verification email
if email_service.is_configured():
# Send verification email asynchronously (smtplib is blocking)
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
# TODO: Get actual verification URL from settings
verification_url = "https://example.com/cabinet/verify-email"
email_service.send_verification_email(
await asyncio.to_thread(
email_service.send_verification_email,
to_email=request.email,
verification_token=verification_token,
verification_url=verification_url,
@@ -361,15 +377,26 @@ async def resend_verification(
await db.commit()
# Send verification email
if email_service.is_configured():
# Send verification email asynchronously (smtplib is blocking)
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
verification_url = "https://example.com/cabinet/verify-email"
email_service.send_verification_email(
await asyncio.to_thread(
email_service.send_verification_email,
to_email=user.email,
verification_token=verification_token,
verification_url=verification_url,
username=user.first_name,
)
elif not settings.is_cabinet_email_verification_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email verification is disabled",
)
elif not email_service.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email service is not configured",
)
return {"message": "Verification email sent"}
@@ -534,10 +561,11 @@ async def forgot_password(
await db.commit()
# Send reset email
# Send reset email asynchronously (smtplib is blocking)
if email_service.is_configured():
reset_url = "https://example.com/cabinet/reset-password"
email_service.send_password_reset_email(
await asyncio.to_thread(
email_service.send_password_reset_email,
to_email=user.email,
reset_token=reset_token,
reset_url=reset_url,
+15 -9
View File
@@ -134,7 +134,7 @@ async def get_payment_methods():
if settings.is_yookassa_enabled():
methods.append(PaymentMethodResponse(
id="yookassa",
name="YooKassa",
name=settings.get_yookassa_display_name(),
description="Pay via YooKassa",
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
@@ -149,7 +149,7 @@ async def get_payment_methods():
if settings.is_cryptobot_enabled():
methods.append(PaymentMethodResponse(
id="cryptobot",
name="CryptoBot",
name=settings.get_cryptobot_display_name(),
description="Pay with cryptocurrency via CryptoBot",
min_amount_kopeks=1000,
max_amount_kopeks=10000000,
@@ -160,7 +160,7 @@ async def get_payment_methods():
if settings.TELEGRAM_STARS_ENABLED:
methods.append(PaymentMethodResponse(
id="telegram_stars",
name="Telegram Stars",
name=settings.get_telegram_stars_display_name(),
description="Pay with Telegram Stars",
min_amount_kopeks=100,
max_amount_kopeks=1000000,
@@ -171,7 +171,7 @@ async def get_payment_methods():
if settings.is_heleket_enabled():
methods.append(PaymentMethodResponse(
id="heleket",
name="Heleket Crypto",
name=settings.get_heleket_display_name(),
description="Pay with cryptocurrency via Heleket",
min_amount_kopeks=1000,
max_amount_kopeks=10000000,
@@ -193,7 +193,7 @@ async def get_payment_methods():
if settings.is_pal24_enabled():
methods.append(PaymentMethodResponse(
id="pal24",
name="PAL24",
name=settings.get_pal24_display_name(),
description="Pay via PAL24",
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
@@ -231,7 +231,7 @@ async def get_payment_methods():
if settings.is_wata_enabled():
methods.append(PaymentMethodResponse(
id="wata",
name="Wata",
name=settings.get_wata_display_name(),
description="Pay via Wata",
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS,
@@ -242,7 +242,7 @@ async def get_payment_methods():
if settings.is_cloudpayments_enabled():
methods.append(PaymentMethodResponse(
id="cloudpayments",
name="CloudPayments",
name=settings.get_cloudpayments_display_name(),
description="Pay with bank card via CloudPayments",
min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS,
@@ -404,12 +404,17 @@ async def create_topup(
# Use payment_option to select card or sbp (default: card)
option = (request.payment_option or "").strip().lower()
# Use description with telegram_id for tax receipts
description = settings.get_balance_payment_description(
request.amount_kopeks,
telegram_user_id=user.telegram_id
)
if option == "sbp":
# Create SBP payment with QR code
result = await yookassa_service.create_sbp_payment(
amount=amount_rubles,
currency="RUB",
description=f"Пополнение баланса на {amount_rubles:.2f}",
description=description,
metadata=yookassa_metadata,
)
else:
@@ -417,7 +422,7 @@ async def create_topup(
result = await yookassa_service.create_payment(
amount=amount_rubles,
currency="RUB",
description=f"Пополнение баланса на {amount_rubles:.2f}",
description=description,
metadata=yookassa_metadata,
)
@@ -645,6 +650,7 @@ async def create_topup(
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
telegram_id=user.telegram_id,
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
+90
View File
@@ -30,6 +30,8 @@ BRANDING_NAME_KEY = "CABINET_BRANDING_NAME"
BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default"
THEME_COLORS_KEY = "CABINET_THEME_COLORS" # Stores JSON with theme colors
ENABLED_THEMES_KEY = "CABINET_ENABLED_THEMES" # Stores JSON with enabled themes {"dark": true, "light": false}
ANIMATION_ENABLED_KEY = "CABINET_ANIMATION_ENABLED" # Stores "true" or "false"
FULLSCREEN_ENABLED_KEY = "CABINET_FULLSCREEN_ENABLED" # Stores "true" or "false"
# Allowed image types
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"}
@@ -95,6 +97,26 @@ class EnabledThemesUpdate(BaseModel):
light: Optional[bool] = None
class AnimationEnabledResponse(BaseModel):
"""Animation enabled setting."""
enabled: bool = True
class AnimationEnabledUpdate(BaseModel):
"""Request to update animation setting."""
enabled: bool
class FullscreenEnabledResponse(BaseModel):
"""Fullscreen enabled setting."""
enabled: bool = False
class FullscreenEnabledUpdate(BaseModel):
"""Request to update fullscreen setting."""
enabled: bool
# Default theme colors
DEFAULT_THEME_COLORS = {
"accent": "#3b82f6",
@@ -504,3 +526,71 @@ async def update_enabled_themes(
logger.info(f"Admin {admin.telegram_id} updated enabled themes: {current_themes}")
return EnabledThemesResponse(**current_themes)
# ============ Animation Routes ============
@router.get("/animation", response_model=AnimationEnabledResponse)
async def get_animation_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get animation enabled setting.
This is a public endpoint - no authentication required.
"""
animation_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if animation_value is not None:
enabled = animation_value.lower() == "true"
return AnimationEnabledResponse(enabled=enabled)
# Default: enabled
return AnimationEnabledResponse(enabled=True)
@router.patch("/animation", response_model=AnimationEnabledResponse)
async def update_animation_enabled(
payload: AnimationEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation enabled setting. Admin only."""
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f"Admin {admin.telegram_id} set animation enabled: {payload.enabled}")
return AnimationEnabledResponse(enabled=payload.enabled)
# ============ Fullscreen Routes ============
@router.get("/fullscreen", response_model=FullscreenEnabledResponse)
async def get_fullscreen_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get fullscreen enabled setting.
This is a public endpoint - no authentication required.
"""
fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY)
if fullscreen_value is not None:
enabled = fullscreen_value.lower() == "true"
return FullscreenEnabledResponse(enabled=enabled)
# Default: disabled
return FullscreenEnabledResponse(enabled=False)
@router.patch("/fullscreen", response_model=FullscreenEnabledResponse)
async def update_fullscreen_enabled(
payload: FullscreenEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update fullscreen enabled setting. Admin only."""
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f"Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}")
return FullscreenEnabledResponse(enabled=payload.enabled)
+47 -15
View File
@@ -42,7 +42,8 @@ class ContestInfo(BaseModel):
slug: str
name: str
description: Optional[str] = None
prize_days: int
prize_type: str
prize_value: str
is_available: bool
already_played: bool = False
@@ -65,7 +66,8 @@ class ContestResult(BaseModel):
"""Result of contest attempt."""
is_winner: bool
message: str
prize_days: Optional[int] = None
prize_type: Optional[str] = None
prize_value: Optional[str] = None
# ============ Helpers ============
@@ -80,19 +82,47 @@ def _user_allowed(subscription) -> bool:
}
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int) -> str:
async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str:
"""Award prize to winner."""
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return "Error: subscription not found"
if prize_type == "days":
try:
days = int(prize_value)
except ValueError:
return "Error: invalid prize value"
subscription.end_date = subscription.end_date + timedelta(days=prize_days)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return "Error: subscription not found"
logger.info(f"🎁 Extended subscription for user {user_id} by {prize_days} days (contest prize)")
return f"Subscription extended by {prize_days} days"
subscription.end_date = subscription.end_date + timedelta(days=days)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Extended subscription for user {user_id} by {days} days (contest prize)")
return f"Subscription extended by {days} days"
elif prize_type == "balance":
from app.database.crud.user import get_user_by_id
try:
amount = float(prize_value)
except ValueError:
return "Error: invalid prize value"
user = await get_user_by_id(db, user_id)
if not user:
return "Error: user not found"
user.balance += amount
await db.commit()
await db.refresh(user)
logger.info(f"🎁 Added {amount} to balance for user {user_id} (contest prize)")
return f"Balance increased by {amount}"
else:
logger.warning(f"Unknown prize type: {prize_type}")
return f"Prize type '{prize_type}' not supported"
# ============ Routes ============
@@ -169,7 +199,8 @@ async def get_contests(
slug=tpl_slug,
name=rnd.template.name if rnd.template else tpl_slug,
description=rnd.template.description if rnd.template else None,
prize_days=rnd.template.prize_days if rnd.template else 0,
prize_type=rnd.template.prize_type if rnd.template else "days",
prize_value=rnd.template.prize_value if rnd.template else "1",
is_available=True,
already_played=attempt is not None,
))
@@ -368,11 +399,12 @@ async def submit_contest_answer(
if is_winner:
await increment_winner_count(db, round_obj)
prize_text = await _award_prize(db, user.id, tpl.prize_days)
prize_text = await _award_prize(db, user.id, tpl.prize_type, tpl.prize_value)
return ContestResult(
is_winner=True,
message=f"🎉 Congratulations! You won! {prize_text}",
prize_days=tpl.prize_days,
prize_type=tpl.prize_type,
prize_value=tpl.prize_value,
)
else:
lose_messages = {
+101 -1
View File
@@ -15,6 +15,8 @@ from app.database.crud.discount_offer import (
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.promo_group import get_auto_assign_promo_groups
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.services.promo_offer_service import promo_offer_service
from app.config import settings
@@ -70,6 +72,29 @@ class PromoGroupDiscounts(BaseModel):
period_discounts: Dict[str, int] = {}
class LoyaltyTierInfo(BaseModel):
"""Info about a single loyalty tier (promo group)."""
id: int
name: str
threshold_rubles: float
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Dict[str, int] = {}
is_current: bool = False
is_achieved: bool = False
class LoyaltyTiersResponse(BaseModel):
"""Response with all loyalty tiers and user progress."""
tiers: List[LoyaltyTierInfo]
current_spent_rubles: float
current_tier_name: Optional[str] = None
next_tier_name: Optional[str] = None
next_tier_threshold_rubles: Optional[float] = None
progress_percent: float = 0
# ============ Routes ============
@router.get("/offers", response_model=List[PromoOfferInfo])
@@ -134,7 +159,7 @@ async def get_promo_group_discounts(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's promo group discounts."""
await db.refresh(user, ["promo_groups"])
await db.refresh(user, ["promo_group", "user_promo_groups"])
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
@@ -160,6 +185,81 @@ async def get_promo_group_discounts(
)
@router.get("/loyalty-tiers", response_model=LoyaltyTiersResponse)
async def get_loyalty_tiers(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all loyalty tiers (promo groups with auto-assign thresholds) and user's progress."""
# Get user's total spent
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
total_spent_rubles = total_spent_kopeks / 100
# Get user's current promo group
await db.refresh(user, ["promo_group", "user_promo_groups"])
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
current_tier_name = current_promo_group.name if current_promo_group else None
# Get all auto-assign promo groups (sorted by threshold ascending)
auto_groups = await get_auto_assign_promo_groups(db)
tiers: List[LoyaltyTierInfo] = []
next_tier_name: Optional[str] = None
next_tier_threshold: Optional[float] = None
for group in auto_groups:
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
threshold_rubles = threshold_kopeks / 100
is_achieved = total_spent_kopeks >= threshold_kopeks
is_current = current_promo_group and current_promo_group.id == group.id
# Get period discounts
period_discounts = {}
raw_period_discounts = getattr(group, "period_discounts", None)
if isinstance(raw_period_discounts, dict):
for key, value in raw_period_discounts.items():
try:
period_discounts[str(key)] = int(value)
except (TypeError, ValueError):
continue
tiers.append(
LoyaltyTierInfo(
id=group.id,
name=group.name,
threshold_rubles=threshold_rubles,
server_discount_percent=group.server_discount_percent or 0,
traffic_discount_percent=group.traffic_discount_percent or 0,
device_discount_percent=group.device_discount_percent or 0,
period_discounts=period_discounts,
is_current=is_current,
is_achieved=is_achieved,
)
)
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Calculate progress to next tier
progress_percent = 0.0
if next_tier_threshold and next_tier_threshold > 0:
progress_percent = min(100.0, (total_spent_rubles / next_tier_threshold) * 100)
elif tiers and all(t.is_achieved for t in tiers):
# All tiers achieved
progress_percent = 100.0
return LoyaltyTiersResponse(
tiers=tiers,
current_spent_rubles=total_spent_rubles,
current_tier_name=current_tier_name,
next_tier_name=next_tier_name,
next_tier_threshold_rubles=next_tier_threshold,
progress_percent=progress_percent,
)
@router.post("/claim", response_model=ClaimOfferResponse)
async def claim_promo_offer(
request: ClaimOfferRequest,
+589 -107
View File
@@ -3,6 +3,7 @@
import base64
import json
import logging
import re
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
@@ -31,6 +32,9 @@ from app.services.subscription_purchase_service import (
PurchaseValidationError,
PurchaseBalanceError,
)
from app.services.user_cart_service import user_cart_service
from app.utils.cache import cache, cache_key, RateLimitCache
from app.utils.promo_offer import get_user_active_promo_discount_percent
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.subscription import (
@@ -129,6 +133,9 @@ def _subscription_to_response(
if last_charge:
next_daily_charge_at = last_charge + timedelta(days=1)
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
hide_link = settings.should_hide_subscription_link()
return SubscriptionResponse(
id=subscription.id,
status=actual_status, # Use actual_status instead of raw status
@@ -148,6 +155,7 @@ def _subscription_to_response(
autopay_enabled=subscription.autopay_enabled or False,
autopay_days_before=subscription.autopay_days_before or 3,
subscription_url=subscription.subscription_url,
hide_subscription_link=hide_link,
is_active=is_active,
is_expired=is_expired,
traffic_purchases=traffic_purchases or [],
@@ -324,31 +332,100 @@ async def renew_subscription(
detail="Invalid renewal period",
)
# Apply discount
discount_percent = 0
# Apply promo group discount
original_price_kopeks = price_kopeks
promo_group_discount_percent = 0
if hasattr(user, "get_promo_discount"):
discount_percent = user.get_promo_discount("period", request.period_days)
promo_group_discount_percent = user.get_promo_discount("period", request.period_days)
if discount_percent > 0:
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
if promo_group_discount_percent > 0:
price_kopeks = int(price_kopeks * (100 - promo_group_discount_percent) / 100)
# Apply promo offer discount (temporary discount from promo offers)
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
promo_offer_discount_value = 0
if promo_offer_discount_percent > 0:
promo_offer_discount_value = price_kopeks * promo_offer_discount_percent // 100
price_kopeks = price_kopeks - promo_offer_discount_value
# Combined discount percent for display
discount_percent = promo_group_discount_percent
if promo_offer_discount_percent > 0 and original_price_kopeks > 0:
total_discount = original_price_kopeks - price_kopeks
discount_percent = int(total_discount * 100 / original_price_kopeks)
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Get tariff info for cart
tariff_id = user.subscription.tariff_id
tariff_name = None
tariff_traffic_limit_gb = None
tariff_device_limit = None
tariff_allowed_squads = None
if tariff_id:
tariff = await get_tariff_by_id(db, tariff_id)
if tariff:
tariff_name = tariff.name
tariff_traffic_limit_gb = tariff.traffic_limit_gb
tariff_device_limit = tariff.device_limit
tariff_allowed_squads = tariff.allowed_squads or []
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'extend',
'subscription_id': user.subscription.id,
'tariff_id': tariff_id,
'period_days': request.period_days,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Продление подписки на {request.period_days} дней" + (f" ({tariff_name})" if tariff_name else ""),
'discount_percent': discount_percent,
'source': 'cabinet',
}
# Add tariff parameters for tariffs mode
if tariff_id:
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
cart_data['device_limit'] = tariff_device_limit
cart_data['allowed_squads'] = tariff_allowed_squads
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f"Cart saved for auto-renewal (cabinet) user {user.id}")
except Exception as e:
logger.error(f"Error saving cart for auto-renewal (cabinet): {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Insufficient balance. Need {price_kopeks / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB",
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
"missing_amount": missing,
"cart_saved": True,
"cart_mode": "extend",
},
)
# Deduct balance and extend subscription
user.balance_kopeks -= price_kopeks
# Consume promo offer discount if it was used
if promo_offer_discount_value > 0:
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
# Extend from end_date or now if expired
now = datetime.utcnow()
if user.subscription.end_date and user.subscription.end_date > now:
from datetime import timedelta
user.subscription.end_date = user.subscription.end_date + timedelta(days=request.period_days)
else:
from datetime import timedelta
user.subscription.end_date = now + timedelta(days=request.period_days)
user.subscription.start_date = now
@@ -357,12 +434,20 @@ async def renew_subscription(
await db.commit()
return {
response = {
"message": "Subscription renewed successfully",
"new_end_date": user.subscription.end_date.isoformat(),
"amount_paid_kopeks": price_kopeks,
}
# Add discount info to response
if promo_offer_discount_value > 0:
response["promo_discount_percent"] = promo_offer_discount_percent
response["promo_discount_amount_kopeks"] = promo_offer_discount_value
response["original_price_kopeks"] = original_price_kopeks
return response
@router.get("/traffic-packages", response_model=List[TrafficPackageResponse])
async def get_traffic_packages(
@@ -586,7 +671,10 @@ async def purchase_traffic(
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(db, subscription)
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error(f"Failed to sync traffic with RemnaWave: {e}")
@@ -891,8 +979,8 @@ async def _build_tariff_response(
periods = []
if tariff.period_prices:
for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])):
if int(price_kopeks) <= 0:
continue # Skip disabled periods
if int(price_kopeks) < 0:
continue # Skip disabled periods (negative price)
period_days = int(period_str)
# Apply promo group discount for this period
@@ -1077,7 +1165,14 @@ async def preview_purchase(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> Dict[str, Any]:
"""Calculate and preview the total price for selected options."""
"""Calculate and preview the total price for selected options (classic mode only)."""
# This endpoint is for classic mode only, tariffs mode uses /purchase-tariff
if settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This endpoint is not available in tariffs mode. Use /purchase-tariff instead.",
)
try:
context = await purchase_service.build_options(db, user)
@@ -1115,7 +1210,14 @@ async def submit_purchase(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> Dict[str, Any]:
"""Submit subscription purchase (deduct from balance)."""
"""Submit subscription purchase (deduct from balance, classic mode only)."""
# This endpoint is for classic mode only, tariffs mode uses /purchase-tariff
if settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This endpoint is not available in tariffs mode. Use /purchase-tariff instead.",
)
try:
context = await purchase_service.build_options(db, user)
@@ -1147,9 +1249,35 @@ async def submit_purchase(
detail=str(e),
)
except PurchaseBalanceError as e:
# Save cart for auto-purchase after balance top-up
try:
total_price = pricing.final_total if 'pricing' in locals() else 0
cart_data = {
'cart_mode': 'subscription_purchase',
'period_id': request.selection.period_id,
'period_days': request.selection.period_days,
'traffic_gb': request.selection.traffic_value, # _prepare_auto_purchase expects traffic_gb
'countries': request.selection.servers, # _prepare_auto_purchase expects countries
'devices': request.selection.devices,
'total_price': total_price,
'user_id': user.id,
'saved_cart': True,
'return_to_cart': True,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f"Cart saved for auto-purchase (cabinet /purchase) user {user.id}")
except Exception as cart_error:
logger.error(f"Error saving cart for auto-purchase (cabinet /purchase): {cart_error}")
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=str(e),
detail={
"code": "insufficient_funds",
"message": str(e),
"cart_saved": True,
"cart_mode": "subscription_purchase",
},
)
except Exception as e:
logger.error(f"Failed to submit purchase for user {user.id}: {e}")
@@ -1262,15 +1390,68 @@ async def purchase_tariff(
traffic_limit_gb = request.traffic_gb
price_kopeks += traffic_price_kopeks
# Apply promo offer discount (temporary discount from promo offers)
price_before_promo_offer = price_kopeks
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
promo_offer_discount_value = 0
if promo_offer_discount_percent > 0:
promo_offer_discount_value = price_kopeks * promo_offer_discount_percent // 100
price_kopeks = price_kopeks - promo_offer_discount_value
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
if is_daily_tariff:
cart_data = {
'cart_mode': 'daily_tariff_purchase',
'tariff_id': tariff.id,
'is_daily': True,
'daily_price_kopeks': price_kopeks,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Покупка суточного тарифа {tariff.name}",
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
'source': 'cabinet',
}
else:
cart_data = {
'cart_mode': 'tariff_purchase',
'tariff_id': tariff.id,
'period_days': period_days,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Покупка тарифа {tariff.name} на {period_days} дней",
'traffic_limit_gb': traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
'source': 'cabinet',
}
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f"Cart saved for auto-purchase (cabinet) user {user.id}, tariff {tariff.id}")
except Exception as e:
logger.error(f"Error saving cart for auto-purchase (cabinet): {e}")
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
"missing_amount": missing,
"cart_saved": True,
"cart_mode": cart_data['cart_mode'],
},
)
@@ -1292,6 +1473,8 @@ async def purchase_tariff(
description = f"Покупка тарифа '{tariff.name}' на {period_days} дней"
if discount_percent > 0:
description += f" (скидка {discount_percent}%)"
if promo_offer_discount_value > 0:
description += f" (промо -{promo_offer_discount_percent}%)"
success = await subtract_user_balance(db, user, price_kopeks, description)
if not success:
raise HTTPException(
@@ -1299,6 +1482,12 @@ async def purchase_tariff(
detail="Failed to charge balance",
)
# Consume promo offer discount if it was used
if promo_offer_discount_value > 0:
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
# Create transaction
await create_transaction(
db=db,
@@ -1339,8 +1528,25 @@ async def purchase_tariff(
await db.refresh(subscription)
# Sync with RemnaWave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
try:
if getattr(user, "remnawave_uuid", None):
await service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="покупка тарифа (cabinet)",
)
else:
await service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="покупка тарифа (cabinet)",
)
except Exception as remnawave_error:
logger.error(f"Failed to sync subscription with RemnaWave: {remnawave_error}")
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
if not is_daily_tariff:
@@ -1378,11 +1584,18 @@ async def purchase_tariff(
response["discount_percent"] = discount_percent
response["original_price_kopeks"] = original_price
response["original_price_label"] = settings.format_price(original_price)
response["discount_amount_kopeks"] = original_price - price_kopeks
response["discount_label"] = settings.format_price(original_price - price_kopeks)
response["discount_amount_kopeks"] = original_price - price_before_promo_offer
response["discount_label"] = settings.format_price(original_price - price_before_promo_offer)
if promo_group:
response["promo_group_name"] = promo_group.name
# Add promo offer discount info if it was applied
if promo_offer_discount_value > 0:
response["promo_offer_discount_percent"] = promo_offer_discount_percent
response["promo_offer_discount_amount_kopeks"] = promo_offer_discount_value
response["promo_offer_discount_label"] = settings.format_price(promo_offer_discount_value)
response["price_before_promo_offer_kopeks"] = price_before_promo_offer
return response
except HTTPException:
@@ -1420,25 +1633,34 @@ async def purchase_devices(
detail="Ваша подписка неактивна",
)
# Get tariff for device price
# Get tariff for device price (if exists)
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff or not tariff.device_price_kopeks:
# Determine device price and max limit from tariff or settings
if tariff and tariff.device_price_kopeks:
device_price = tariff.device_price_kopeks
max_device_limit = tariff.max_device_limit
else:
# Classic mode - use settings
device_price = settings.PRICE_PER_DEVICE
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
if not device_price or device_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Докупка устройств недоступна для вашего тарифа",
detail="Докупка устройств недоступна",
)
# Check max device limit
current_devices = subscription.device_limit or 1
new_device_count = current_devices + request.devices
if tariff.max_device_limit and new_device_count > tariff.max_device_limit:
if max_device_limit and new_device_count > max_device_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Максимальное количество устройств для вашего тарифа: {tariff.max_device_limit}",
detail=f"Максимальное количество устройств: {max_device_limit}",
)
# Calculate prorated price based on remaining days
@@ -1452,7 +1674,7 @@ async def purchase_devices(
total_days = 30 # Base period for device price calculation
# Price = device_price * devices * (days_left / 30)
price_kopeks = int(tariff.device_price_kopeks * request.devices * days_left / total_days)
price_kopeks = int(device_price * request.devices * days_left / total_days)
price_kopeks = max(100, price_kopeks) # Minimum 1 ruble
# Check balance
@@ -1484,7 +1706,13 @@ async def purchase_devices(
# Sync with RemnaWave
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
try:
if getattr(user, "remnawave_uuid", None):
await service.update_remnawave_user(db, subscription)
else:
await service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error(f"Failed to sync devices with RemnaWave: {e}")
await db.refresh(user)
@@ -1534,15 +1762,23 @@ async def get_device_price(
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff or not tariff.device_price_kopeks:
# Determine device price and max limit from tariff or settings
if tariff and tariff.device_price_kopeks:
device_price = tariff.device_price_kopeks
max_device_limit = tariff.max_device_limit
else:
# Classic mode - use settings
device_price = settings.PRICE_PER_DEVICE
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
if not device_price or device_price <= 0:
return {
"available": False,
"reason": "Докупка устройств недоступна для вашего тарифа",
"reason": "Докупка устройств недоступна",
}
# Check max device limit
current_devices = subscription.device_limit or 1
max_device_limit = tariff.max_device_limit
can_add = max_device_limit - current_devices if max_device_limit else None
if max_device_limit and current_devices >= max_device_limit:
@@ -1572,7 +1808,7 @@ async def get_device_price(
days_left = max(1, (end_date - now).days)
total_days = 30
price_per_device_kopeks = int(tariff.device_price_kopeks * days_left / total_days)
price_per_device_kopeks = int(device_price * days_left / total_days)
price_per_device_kopeks = max(100, price_per_device_kopeks)
total_price_kopeks = price_per_device_kopeks * devices
@@ -1587,7 +1823,7 @@ async def get_device_price(
"max_device_limit": max_device_limit,
"can_add": can_add,
"days_left": days_left,
"base_device_price_kopeks": tariff.device_price_kopeks,
"base_device_price_kopeks": device_price,
}
@@ -1659,67 +1895,127 @@ def _convert_remnawave_block_to_step(block: Dict[str, Any], url_scheme: str = ""
return step
# Known app URL schemes (fallback if RemnaWave doesn't provide urlScheme)
KNOWN_APP_URL_SCHEMES = {
# iOS
"happ": "happ://add/",
"streisand": "streisand://import/",
"shadowrocket": "sub://",
"shadow rocket": "sub://",
"karing": "karing://install-config?url=",
"foxray": "foxray://yiguo.dev/sub/add/?url=",
"fox ray": "foxray://yiguo.dev/sub/add/?url=",
"v2box": "v2box://install-sub?url=",
"sing-box": "sing-box://import-remote-profile?url=",
"singbox": "sing-box://import-remote-profile?url=",
"quantumult x": "quantumult-x://add-resource?remote-resource=",
"quantumultx": "quantumult-x://add-resource?remote-resource=",
"quantumult": "quantumult-x://add-resource?remote-resource=",
"surge": "surge3://install-config?url=",
"loon": "loon://import?sub=",
"stash": "stash://install-config?url=",
# Android
"v2rayn": "v2rayng://install-sub?url=",
"v2rayng": "v2rayng://install-sub?url=",
"v2ray ng": "v2rayng://install-sub?url=",
"nekoray": "sn://subscription?url=",
"nekobox": "sn://subscription?url=",
"neko ray": "sn://subscription?url=",
"neko box": "sn://subscription?url=",
"surfboard": "surfboard://install-config?url=",
# PC (Windows/macOS/Linux)
"clash": "clash://install-config?url=",
"clash meta": "clash://install-config?url=",
"clash verge": "clash://install-config?url=",
"clash verge rev": "clash://install-config?url=",
"clashx": "clashx://install-config?url=",
"clashx meta": "clash://install-config?url=",
"clashx pro": "clash://install-config?url=",
"flclash": "clash://install-config?url=",
"flclashx": "clash://install-config?url=",
"koala clash": "clash://install-config?url=",
"koalaclash": "clash://install-config?url=",
"hiddify": "hiddify://install-config/?url=",
"hiddify next": "hiddify://install-config/?url=",
"mihomo party": "clash://install-config?url=",
"mihomo": "clash://install-config?url=",
}
def _extract_scheme_from_buttons(buttons: List[Dict[str, Any]]) -> str:
"""Extract URL scheme from buttons list."""
for btn in buttons:
if not isinstance(btn, dict):
continue
link = btn.get("link", "") or btn.get("url", "") or btn.get("buttonLink", "")
if not link:
continue
# Check for subscription link placeholder (case-insensitive)
link_upper = link.upper()
if "{{SUBSCRIPTION_LINK}}" in link_upper or "SUBSCRIPTION_LINK" in link_upper:
# Extract scheme: "prizrak-box://install-config?url={{SUBSCRIPTION_LINK}}" -> "prizrak-box://install-config?url="
scheme = re.sub(r'\{\{SUBSCRIPTION_LINK\}\}', '', link, flags=re.IGNORECASE)
if scheme and "://" in scheme:
return scheme
# Also check for type="subscriptionLink" buttons with custom schemes
btn_type = btn.get("type", "")
if btn_type == "subscriptionLink" and "://" in link and not link.startswith("http"):
# Extract base scheme from link like "prizrak-box://install-config?url="
scheme = link.split("{{")[0] if "{{" in link else link
if scheme and "://" in scheme:
return scheme
return ""
def _get_url_scheme_for_app(app: Dict[str, Any]) -> str:
"""Get URL scheme for app - from config, buttons, or fallback by name."""
# 1. Check urlScheme field
scheme = str(app.get("urlScheme", "")).strip()
if scheme:
return scheme
# 2. Extract from buttons in blocks (RemnaWave format)
blocks = app.get("blocks", [])
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get("buttons", [])
scheme = _extract_scheme_from_buttons(buttons)
if scheme:
return scheme
# 3. Check buttons directly in app (alternative structure)
direct_buttons = app.get("buttons", [])
if direct_buttons:
scheme = _extract_scheme_from_buttons(direct_buttons)
if scheme:
return scheme
# 4. Check in step structures (cabinet format)
for step_key in ["installationStep", "addSubscriptionStep", "connectAndUseStep"]:
step = app.get(step_key, {})
if isinstance(step, dict):
step_buttons = step.get("buttons", [])
scheme = _extract_scheme_from_buttons(step_buttons)
if scheme:
return scheme
# No scheme found
logger.debug(f"_get_url_scheme_for_app: No scheme found for app '{app.get('name')}', "
f"has blocks: {bool(app.get('blocks'))}, "
f"has buttons: {bool(app.get('buttons'))}, "
f"has urlScheme: {bool(app.get('urlScheme'))}")
return ""
def _find_subscription_block(blocks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Find block that contains subscriptionLink button."""
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get("buttons", [])
for btn in buttons:
if not isinstance(btn, dict):
continue
# Check for subscriptionLink type or {{SUBSCRIPTION_LINK}} in link
btn_type = btn.get("type", "")
link = btn.get("link", "") or btn.get("url", "")
if btn_type == "subscriptionLink" or (link and "SUBSCRIPTION_LINK" in link.upper()):
return block
return None
def _find_connect_block(blocks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Find block that is about connection/usage (usually last or has specific keywords)."""
# Look for block with "connect" or "use" in title
for block in blocks:
if not isinstance(block, dict):
continue
title = block.get("title", {})
title_en = title.get("en", "") if isinstance(title, dict) else ""
title_lower = title_en.lower()
if "connect" in title_lower or "use" in title_lower:
return block
# Fallback to last block if no match
return blocks[-1] if blocks else None
def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]:
"""Convert RemnaWave app format to cabinet app format."""
blocks = app.get("blocks", [])
url_scheme = app.get("urlScheme", "")
url_scheme = _get_url_scheme_for_app(app)
# If urlScheme is missing, try to determine from app name
if not url_scheme:
app_name = app.get("name", "").lower().strip()
url_scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "")
# Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально)
app_name = app.get("name", "unknown")
if url_scheme:
logger.debug(f"_convert_remnawave_app_to_cabinet: app '{app_name}' -> urlScheme='{url_scheme}'")
# Map blocks to steps based on position
installation_step = _convert_remnawave_block_to_step(blocks[0], url_scheme) if len(blocks) > 0 else {"description": {}}
subscription_step = _convert_remnawave_block_to_step(blocks[1], url_scheme) if len(blocks) > 1 else {"description": {}}
connect_step = _convert_remnawave_block_to_step(blocks[2], url_scheme) if len(blocks) > 2 else {"description": {}}
# Smart block mapping: find blocks by their content, not just position
# 1. First block is usually installation
installation_block = blocks[0] if len(blocks) > 0 else None
# 2. Find subscription block (with subscriptionLink button)
subscription_block = _find_subscription_block(blocks)
# 3. Find connect/use block (usually last or has "connect" in title)
connect_block = _find_connect_block(blocks)
# Convert blocks to steps
installation_step = _convert_remnawave_block_to_step(installation_block, url_scheme) if installation_block else {"description": {}}
subscription_step = _convert_remnawave_block_to_step(subscription_block, url_scheme) if subscription_block else {"description": {}}
connect_step = _convert_remnawave_block_to_step(connect_block, url_scheme) if connect_block else {"description": {}}
# Ensure subscription step has a deepLink button if urlScheme exists
if url_scheme:
@@ -1749,7 +2045,7 @@ def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]:
"id": app.get("name", "").lower().replace(" ", "-"),
"name": app.get("name", ""),
"isFeatured": app.get("featured", False),
"urlScheme": url_scheme, # Use resolved url_scheme (with fallback from app name)
"urlScheme": url_scheme,
"isNeedBase64Encoding": app.get("isNeedBase64Encoding", False),
"installationStep": installation_step,
"addSubscriptionStep": subscription_step,
@@ -1829,22 +2125,36 @@ def _load_app_config() -> Dict[str, Any]:
return _load_app_config_from_file()
def _create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
"""Create deep link for app with subscription URL."""
if not subscription_url or not isinstance(app, dict):
logger.debug(f"_create_deep_link: no subscription_url or invalid app")
def _is_happ_app(app: Dict[str, Any]) -> bool:
"""Check if app is Happ (uses happ_cryptolink scheme)."""
name = str(app.get("name", "")).lower()
svg_icon_key = str(app.get("svgIconKey", "")).lower()
return name == "happ" or svg_icon_key == "happ"
def _create_deep_link(
app: Dict[str, Any],
subscription_url: str,
subscription_crypto_link: Optional[str] = None
) -> Optional[str]:
"""Create deep link for app with subscription URL.
Uses urlScheme from RemnaWave config or fallback by app name.
For Happ apps, uses subscription_crypto_link directly (contains happ:// scheme).
"""
if not isinstance(app, dict):
return None
scheme = str(app.get("urlScheme", "")).strip()
if not scheme:
# Try fallback from app name
app_name = app.get("name", "").lower().strip()
scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "")
if scheme:
logger.info(f"_create_deep_link: used fallback urlScheme for '{app_name}': {scheme}")
# For Happ, use crypto_link directly if available (already has happ:// scheme)
if _is_happ_app(app) and subscription_crypto_link:
return subscription_crypto_link
if not subscription_url:
return None
scheme = _get_url_scheme_for_app(app)
if not scheme:
logger.warning(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'")
logger.debug(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'")
return None
payload = subscription_url
@@ -1868,6 +2178,7 @@ async def get_available_countries(
) -> Dict[str, Any]:
"""Get available countries/servers for the user."""
from app.database.crud.server_squad import get_available_server_squads
from app.utils.pricing_utils import calculate_prorated_price, apply_percentage_discount
await db.refresh(user, ["subscription"])
@@ -1878,25 +2189,59 @@ async def get_available_countries(
)
connected_squads = []
days_left = 0
if user.subscription:
connected_squads = user.subscription.connected_squads or []
# Calculate days left for prorated pricing
if user.subscription.end_date:
from datetime import datetime
delta = user.subscription.end_date - datetime.utcnow()
days_left = max(0, delta.days)
# Get discount from promo group
servers_discount_percent = 0
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group:
servers_discount_percent = promo_group.get_discount_percent("servers", None)
countries = []
for server in available_servers:
base_price = server.price_kopeks
# Apply discount
if servers_discount_percent > 0:
discounted_price, _ = apply_percentage_discount(base_price, servers_discount_percent)
else:
discounted_price = base_price
# Calculate prorated price if subscription exists
prorated_price = discounted_price
if user.subscription and user.subscription.end_date:
prorated_price, _ = calculate_prorated_price(
discounted_price,
user.subscription.end_date,
)
countries.append({
"uuid": server.squad_uuid,
"name": server.display_name,
"country_code": server.country_code,
"price_kopeks": server.price_kopeks,
"price_rubles": server.price_kopeks / 100,
"base_price_kopeks": base_price,
"price_kopeks": prorated_price, # Prorated price with discount
"price_per_month_kopeks": discounted_price, # Monthly price with discount
"price_rubles": prorated_price / 100,
"is_available": server.is_available and not server.is_full,
"is_connected": server.squad_uuid in connected_squads,
"has_discount": servers_discount_percent > 0,
"discount_percent": servers_discount_percent,
})
return {
"countries": countries,
"connected_count": len(connected_squads),
"has_subscription": user.subscription is not None,
"days_left": days_left,
"discount_percent": servers_discount_percent,
}
@@ -2038,7 +2383,10 @@ async def update_countries(
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(db, user.subscription)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f"Failed to sync countries with RemnaWave: {e}")
@@ -2157,8 +2505,10 @@ async def get_app_config(
await db.refresh(user, ["subscription"])
subscription_url = None
subscription_crypto_link = None
if user.subscription:
subscription_url = user.subscription.subscription_url
subscription_crypto_link = user.subscription.subscription_crypto_link
# Load config from RemnaWave (if configured) or local file
config = await _load_app_config_async()
@@ -2190,8 +2540,8 @@ async def get_app_config(
}
# Add deep link if subscription exists
if subscription_url:
app_data["deepLink"] = _create_deep_link(app, subscription_url)
if subscription_url or subscription_crypto_link:
app_data["deepLink"] = _create_deep_link(app, subscription_url, subscription_crypto_link)
platform_apps.append(app_data)
@@ -2209,11 +2559,15 @@ async def get_app_config(
"appleTV": {"ru": "Apple TV", "en": "Apple TV"},
}
hide_link = settings.should_hide_subscription_link()
return {
"platforms": platforms,
"platformNames": platform_names,
"hasSubscription": bool(subscription_url),
"subscriptionUrl": subscription_url,
"hasSubscription": bool(subscription_url or subscription_crypto_link),
"subscriptionUrl": subscription_url if not hide_link else None,
"subscriptionCryptoLink": subscription_crypto_link if not hide_link else None,
"hideLink": hide_link,
"branding": config.get("config", {}).get("branding", {}),
}
@@ -2706,7 +3060,10 @@ async def switch_tariff(
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(db, user.subscription)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f"Failed to sync tariff switch with RemnaWave: {e}")
@@ -2917,7 +3274,10 @@ async def switch_traffic_package(
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(db, user.subscription)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f"Failed to sync traffic switch with RemnaWave: {e}")
@@ -2933,3 +3293,125 @@ async def switch_traffic_package(
"balance_kopeks": user.balance_kopeks,
"balance_label": settings.format_price(user.balance_kopeks),
}
# ============ Traffic Refresh ============
# Rate limit: 1 request per 60 seconds per user
TRAFFIC_REFRESH_RATE_LIMIT = 1
TRAFFIC_REFRESH_RATE_WINDOW = 60 # seconds
TRAFFIC_CACHE_TTL = 60 # Cache traffic data for 60 seconds
@router.post("/refresh-traffic")
async def refresh_traffic(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Refresh traffic usage from RemnaWave panel.
Rate limited to 1 request per 60 seconds.
"""
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active subscription",
)
# Check rate limit
is_limited = await RateLimitCache.is_rate_limited(
user.telegram_id,
"traffic_refresh",
TRAFFIC_REFRESH_RATE_LIMIT,
TRAFFIC_REFRESH_RATE_WINDOW,
)
if is_limited:
# Check if we have cached data
traffic_cache_key = cache_key("traffic", user.telegram_id)
cached_data = await cache.get(traffic_cache_key)
if cached_data:
return {
"success": True,
"cached": True,
"rate_limited": True,
"retry_after_seconds": TRAFFIC_REFRESH_RATE_WINDOW,
**cached_data,
}
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Rate limited. Try again in {TRAFFIC_REFRESH_RATE_WINDOW} seconds.",
headers={"Retry-After": str(TRAFFIC_REFRESH_RATE_WINDOW)},
)
# Fetch traffic from RemnaWave
try:
remnawave_service = RemnaWaveService()
traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id)
if not traffic_stats:
# Return current database values if RemnaWave unavailable
traffic_data = {
"traffic_used_bytes": int((user.subscription.traffic_used_gb or 0) * (1024**3)),
"traffic_used_gb": round(user.subscription.traffic_used_gb or 0, 2),
"traffic_limit_bytes": int((user.subscription.traffic_limit_gb or 0) * (1024**3)),
"traffic_limit_gb": user.subscription.traffic_limit_gb or 0,
"traffic_used_percent": round(
((user.subscription.traffic_used_gb or 0) / (user.subscription.traffic_limit_gb or 1)) * 100
if user.subscription.traffic_limit_gb
else 0,
1,
),
"is_unlimited": (user.subscription.traffic_limit_gb or 0) == 0,
}
return {
"success": True,
"cached": False,
"source": "database",
**traffic_data,
}
# Update subscription with fresh data
used_gb = traffic_stats.get("used_traffic_gb", 0)
if abs((user.subscription.traffic_used_gb or 0) - used_gb) > 0.01:
user.subscription.traffic_used_gb = used_gb
user.subscription.updated_at = datetime.utcnow()
await db.commit()
# Calculate percentage
limit_gb = user.subscription.traffic_limit_gb or 0
if limit_gb > 0:
percent = min(100, (used_gb / limit_gb) * 100)
else:
percent = 0
traffic_data = {
"traffic_used_bytes": traffic_stats.get("used_traffic_bytes", 0),
"traffic_used_gb": round(used_gb, 2),
"traffic_limit_bytes": traffic_stats.get("traffic_limit_bytes", 0),
"traffic_limit_gb": limit_gb,
"traffic_used_percent": round(percent, 1),
"is_unlimited": limit_gb == 0,
"lifetime_used_bytes": traffic_stats.get("lifetime_used_traffic_bytes", 0),
"lifetime_used_gb": round(traffic_stats.get("lifetime_used_traffic_gb", 0), 2),
}
# Cache the result
traffic_cache_key = cache_key("traffic", user.telegram_id)
await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL)
return {
"success": True,
"cached": False,
"source": "remnawave",
**traffic_data,
}
except Exception as e:
logger.error(f"Error refreshing traffic for user {user.telegram_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to refresh traffic data",
)
+206
View File
@@ -0,0 +1,206 @@
"""Ticket notifications routes for cabinet."""
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.database.models import User
from app.database.crud.ticket_notification import TicketNotificationCRUD
from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tickets/notifications", tags=["Cabinet Ticket Notifications"])
admin_router = APIRouter(prefix="/admin/tickets/notifications", tags=["Cabinet Admin Ticket Notifications"])
# Schemas
class TicketNotificationResponse(BaseModel):
"""Single ticket notification."""
id: int
ticket_id: int
notification_type: str
message: Optional[str] = None
is_read: bool
created_at: datetime
read_at: Optional[datetime] = None
class Config:
from_attributes = True
class TicketNotificationListResponse(BaseModel):
"""List of ticket notifications."""
items: List[TicketNotificationResponse]
unread_count: int
class UnreadCountResponse(BaseModel):
"""Unread notifications count."""
unread_count: int
# User endpoints
@router.get("", response_model=TicketNotificationListResponse)
async def get_user_notifications(
unread_only: bool = Query(False, description="Only return unread notifications"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for current user."""
notifications = await TicketNotificationCRUD.get_user_notifications(
db, user.id, unread_only=unread_only, limit=limit, offset=offset
)
unread_count = await TicketNotificationCRUD.count_unread_user(db, user.id)
return TicketNotificationListResponse(
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
unread_count=unread_count,
)
@router.get("/unread-count", response_model=UnreadCountResponse)
async def get_user_unread_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for current user."""
count = await TicketNotificationCRUD.count_unread_user(db, user.id)
return UnreadCountResponse(unread_count=count)
@router.post("/{notification_id}/read")
async def mark_notification_as_read(
notification_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a notification as read."""
# Security: Verify notification belongs to current user and is not an admin notification
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
)
# Check ownership: notification must belong to user and not be an admin notification
if notification.user_id != user.id or notification.is_for_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to mark this notification as read",
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {"success": True}
@router.post("/read-all")
async def mark_all_notifications_as_read(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all notifications as read for current user."""
count = await TicketNotificationCRUD.mark_all_as_read_user(db, user.id)
return {"success": True, "marked_count": count}
@router.post("/ticket/{ticket_id}/read")
async def mark_ticket_notifications_as_read(
ticket_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all notifications for a specific ticket as read."""
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(
db, ticket_id, user.id, is_admin=False
)
return {"success": True, "marked_count": count}
# Admin endpoints
@admin_router.get("", response_model=TicketNotificationListResponse)
async def get_admin_notifications(
unread_only: bool = Query(False, description="Only return unread notifications"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for admins."""
notifications = await TicketNotificationCRUD.get_admin_notifications(
db, unread_only=unread_only, limit=limit, offset=offset
)
unread_count = await TicketNotificationCRUD.count_unread_admin(db)
return TicketNotificationListResponse(
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
unread_count=unread_count,
)
@admin_router.get("/unread-count", response_model=UnreadCountResponse)
async def get_admin_unread_count(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for admins."""
count = await TicketNotificationCRUD.count_unread_admin(db)
return UnreadCountResponse(unread_count=count)
@admin_router.post("/{notification_id}/read")
async def mark_admin_notification_as_read(
notification_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark an admin notification as read."""
# Security: Verify notification exists and is an admin notification
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
)
# Check that this is actually an admin notification
if not notification.is_for_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This is not an admin notification",
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {"success": True}
@admin_router.post("/read-all")
async def mark_all_admin_notifications_as_read(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications as read."""
count = await TicketNotificationCRUD.mark_all_as_read_admin(db)
return {"success": True, "marked_count": count}
@admin_router.post("/ticket/{ticket_id}/read")
async def mark_admin_ticket_notifications_as_read(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications for a specific ticket as read."""
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(
db, ticket_id, admin.id, is_admin=True
)
return {"success": True, "marked_count": count}
+35
View File
@@ -12,6 +12,9 @@ from sqlalchemy.orm import selectinload
from app.database.models import User, Ticket, TicketMessage
from app.config import settings
from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.cabinet.routes.websocket import notify_admins_new_ticket, notify_admins_ticket_reply
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.tickets import (
@@ -161,6 +164,21 @@ async def create_ticket(
# Refresh to get relationships
await db.refresh(ticket, ["messages"])
# Уведомить админов о новом тикете (Telegram)
try:
await notify_admins_about_new_ticket(ticket, db)
except Exception as e:
logger.error(f"Error notifying admins about new ticket from cabinet: {e}")
# Уведомить админов в кабинете
try:
notification = await TicketNotificationCRUD.create_admin_notification_for_new_ticket(db, ticket)
if notification:
# Отправить WebSocket уведомление
await notify_admins_new_ticket(ticket.id, ticket.title, user.id)
except Exception as e:
logger.error(f"Error creating cabinet notification for new ticket: {e}")
messages = [_message_to_response(m) for m in ticket.messages]
return TicketDetailResponse(
@@ -268,4 +286,21 @@ async def add_ticket_message(
await db.commit()
await db.refresh(message)
# Уведомить админов об ответе пользователя (Telegram)
try:
await notify_admins_about_ticket_reply(ticket, request.message, db)
except Exception as e:
logger.error(f"Error notifying admins about ticket reply from cabinet: {e}")
# Уведомить админов в кабинете
try:
notification = await TicketNotificationCRUD.create_admin_notification_for_user_reply(
db, ticket, request.message
)
if notification:
# Отправить WebSocket уведомление
await notify_admins_ticket_reply(ticket.id, (request.message or "")[:100], user.id)
except Exception as e:
logger.error(f"Error creating cabinet notification for user reply: {e}")
return _message_to_response(message)
+251
View File
@@ -0,0 +1,251 @@
"""WebSocket endpoint for cabinet real-time notifications."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Dict, Set
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.database.database import AsyncSessionLocal
from app.database.crud.user import get_user_by_id
from app.config import settings
from app.cabinet.auth.jwt_handler import get_token_payload
logger = logging.getLogger(__name__)
router = APIRouter()
class CabinetConnectionManager:
"""Менеджер WebSocket подключений для кабинета."""
def __init__(self):
# user_id -> set of websocket connections
self._user_connections: Dict[int, Set[WebSocket]] = {}
# admin user_ids -> set of websocket connections
self._admin_connections: Dict[int, Set[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None:
"""Зарегистрировать подключение."""
async with self._lock:
if user_id not in self._user_connections:
self._user_connections[user_id] = set()
self._user_connections[user_id].add(websocket)
if is_admin:
if user_id not in self._admin_connections:
self._admin_connections[user_id] = set()
self._admin_connections[user_id].add(websocket)
logger.debug(
"Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d",
user_id, is_admin, len(self._user_connections)
)
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
"""Отменить регистрацию подключения."""
async with self._lock:
if user_id in self._user_connections:
self._user_connections[user_id].discard(websocket)
if not self._user_connections[user_id]:
del self._user_connections[user_id]
if user_id in self._admin_connections:
self._admin_connections[user_id].discard(websocket)
if not self._admin_connections[user_id]:
del self._admin_connections[user_id]
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
async def send_to_user(self, user_id: int, message: dict) -> None:
"""Отправить сообщение конкретному пользователю."""
# Snapshot connections under the lock to avoid mutation during iteration
async with self._lock:
connections = list(self._user_connections.get(user_id, set()))
if not connections:
return
disconnected = set()
data = json.dumps(message, default=str, ensure_ascii=False)
for ws in connections:
try:
await ws.send_text(data)
except Exception as e:
logger.warning("Failed to send to user %d: %s", user_id, e)
disconnected.add(ws)
# Cleanup disconnected
if disconnected:
async with self._lock:
for ws in disconnected:
self._user_connections.get(user_id, set()).discard(ws)
async def send_to_admins(self, message: dict) -> None:
"""Отправить сообщение всем админам."""
# Snapshot connections under the lock to avoid mutation during iteration
async with self._lock:
if not self._admin_connections:
return
# Create a snapshot: list of (user_id, list of websockets)
admin_snapshot = [
(user_id, list(connections))
for user_id, connections in self._admin_connections.items()
]
data = json.dumps(message, default=str, ensure_ascii=False)
disconnected_by_user: Dict[int, Set[WebSocket]] = {}
for user_id, connections in admin_snapshot:
for ws in connections:
try:
await ws.send_text(data)
except Exception as e:
logger.warning("Failed to send to admin %d: %s", user_id, e)
if user_id not in disconnected_by_user:
disconnected_by_user[user_id] = set()
disconnected_by_user[user_id].add(ws)
# Cleanup disconnected
if disconnected_by_user:
async with self._lock:
for user_id, ws_set in disconnected_by_user.items():
for ws in ws_set:
self._admin_connections.get(user_id, set()).discard(ws)
# Глобальный менеджер подключений
cabinet_ws_manager = CabinetConnectionManager()
async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
"""
Проверить JWT токен для WebSocket.
Returns:
tuple[user_id, is_admin] или (None, False) если токен невалидный
"""
if not token:
return None, False
payload = get_token_payload(token, expected_type="access")
if not payload:
return None, False
try:
user_id = int(payload.get("sub"))
except (TypeError, ValueError):
return None, False
async with AsyncSessionLocal() as db:
user = await get_user_by_id(db, user_id)
if not user or user.status != "active":
return None, False
is_admin = settings.is_admin(user.telegram_id)
return user_id, is_admin
@router.websocket("/ws")
async def cabinet_websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint для real-time уведомлений кабинета."""
client_host = websocket.client.host if websocket.client else "unknown"
# Получаем токен из query params
token = websocket.query_params.get("token")
if not token:
logger.debug("Cabinet WS: No token from %s", client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: No token")
return
# Верифицируем токен
user_id, is_admin = await verify_cabinet_ws_token(token)
if not user_id:
logger.debug("Cabinet WS: Invalid token from %s", client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: Invalid token")
return
# Принимаем соединение
try:
await websocket.accept()
logger.debug("Cabinet WS accepted: user_id=%d, is_admin=%s", user_id, is_admin)
except Exception as e:
logger.error("Cabinet WS: Failed to accept from %s: %s", client_host, e)
return
# Регистрируем подключение
await cabinet_ws_manager.connect(websocket, user_id, is_admin)
try:
# Приветственное сообщение
await websocket.send_json({
"type": "connected",
"user_id": user_id,
"is_admin": is_admin,
})
# Обрабатываем входящие сообщения
while True:
try:
data = await websocket.receive_text()
message = json.loads(data)
# Ping/pong для keepalive
if message.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
logger.warning("Cabinet WS: Invalid JSON from user %d", user_id)
except WebSocketDisconnect:
break
except Exception as e:
logger.exception("Cabinet WS error for user %d: %s", user_id, e)
break
except WebSocketDisconnect:
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
except Exception as e:
logger.exception("Cabinet WS error: %s", e)
finally:
await cabinet_ws_manager.disconnect(websocket, user_id)
# Функции для отправки уведомлений (используются из других модулей)
async def notify_user_ticket_reply(user_id: int, ticket_id: int, message: str) -> None:
"""Уведомить пользователя об ответе в тикете."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "ticket.admin_reply",
"ticket_id": ticket_id,
"message": message,
})
async def notify_admins_new_ticket(ticket_id: int, title: str, user_id: int) -> None:
"""Уведомить админов о новом тикете."""
await cabinet_ws_manager.send_to_admins({
"type": "ticket.new",
"ticket_id": ticket_id,
"title": title,
"user_id": user_id,
})
async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int) -> None:
"""Уведомить админов об ответе пользователя."""
await cabinet_ws_manager.send_to_admins({
"type": "ticket.user_reply",
"ticket_id": ticket_id,
"message": message,
"user_id": user_id,
})
+1
View File
@@ -42,6 +42,7 @@ class SubscriptionResponse(BaseModel):
autopay_enabled: bool
autopay_days_before: int
subscription_url: Optional[str] = None
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
is_active: bool
is_expired: bool
traffic_purchases: List[TrafficPurchaseInfo] = []
+66 -1
View File
@@ -294,6 +294,7 @@ class Settings(BaseSettings):
TELEGRAM_STARS_ENABLED: bool = True
TELEGRAM_STARS_RATE_RUB: float = 1.3
TELEGRAM_STARS_DISPLAY_NAME: str = "Telegram Stars"
TRIBUTE_ENABLED: bool = False
TRIBUTE_API_KEY: Optional[str] = None
@@ -303,6 +304,7 @@ class Settings(BaseSettings):
TRIBUTE_WEBHOOK_PORT: int = 8081
YOOKASSA_ENABLED: bool = False
YOOKASSA_DISPLAY_NAME: str = "YooKassa"
YOOKASSA_SHOP_ID: Optional[str] = None
YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None
@@ -348,6 +350,7 @@ class Settings(BaseSettings):
PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}"
CRYPTOBOT_ENABLED: bool = False
CRYPTOBOT_DISPLAY_NAME: str = "CryptoBot"
CRYPTOBOT_API_TOKEN: Optional[str] = None
CRYPTOBOT_WEBHOOK_SECRET: Optional[str] = None
CRYPTOBOT_BASE_URL: str = "https://pay.crypt.bot"
@@ -359,6 +362,7 @@ class Settings(BaseSettings):
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
HELEKET_ENABLED: bool = False
HELEKET_DISPLAY_NAME: str = "Heleket Crypto"
HELEKET_MERCHANT_ID: Optional[str] = None
HELEKET_API_KEY: Optional[str] = None
HELEKET_BASE_URL: str = "https://api.heleket.com/v1"
@@ -394,6 +398,7 @@ class Settings(BaseSettings):
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
PAL24_ENABLED: bool = False
PAL24_DISPLAY_NAME: str = "PAL24"
PAL24_API_TOKEN: Optional[str] = None
PAL24_SHOP_ID: Optional[str] = None
PAL24_SIGNATURE_TOKEN: Optional[str] = None
@@ -425,6 +430,7 @@ class Settings(BaseSettings):
PLATEGA_WEBHOOK_PORT: int = 8086
WATA_ENABLED: bool = False
WATA_DISPLAY_NAME: str = "Wata"
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
WATA_ACCESS_TOKEN: Optional[str] = None
WATA_TERMINAL_PUBLIC_ID: Optional[str] = None
@@ -444,6 +450,7 @@ class Settings(BaseSettings):
# CloudPayments
CLOUDPAYMENTS_ENABLED: bool = False
CLOUDPAYMENTS_DISPLAY_NAME: str = "CloudPayments"
CLOUDPAYMENTS_PUBLIC_ID: Optional[str] = None
CLOUDPAYMENTS_API_SECRET: Optional[str] = None
CLOUDPAYMENTS_API_URL: str = "https://api.cloudpayments.ru"
@@ -481,6 +488,21 @@ class Settings(BaseSettings):
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
SERVER_PUBLIC_IP: Optional[str] = None
# KassaAI (api.fk.life) - отдельная платёжка
KASSA_AI_ENABLED: bool = False
KASSA_AI_SHOP_ID: Optional[int] = None
KASSA_AI_API_KEY: Optional[str] = None
KASSA_AI_SECRET_WORD_2: Optional[str] = None # Для webhook
KASSA_AI_DISPLAY_NAME: str = "KassaAI"
KASSA_AI_CURRENCY: str = "RUB"
KASSA_AI_MIN_AMOUNT_KOPEKS: int = 10000 # 100 руб
KASSA_AI_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000 руб
KASSA_AI_WEBHOOK_PATH: str = "/kassa-ai-webhook"
KASSA_AI_WEBHOOK_HOST: str = "0.0.0.0"
KASSA_AI_WEBHOOK_PORT: int = 8089
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -1481,6 +1503,10 @@ class Settings(BaseSettings):
self.YOOKASSA_SHOP_ID is not None and
self.YOOKASSA_SECRET_KEY is not None)
def get_yookassa_display_name(self) -> str:
name = (self.YOOKASSA_DISPLAY_NAME or "").strip()
return name if name else "YooKassa"
def is_nalogo_enabled(self) -> bool:
return (self.NALOGO_ENABLED and
self.NALOGO_INN is not None and
@@ -1500,6 +1526,10 @@ class Settings(BaseSettings):
return (self.CRYPTOBOT_ENABLED and
self.CRYPTOBOT_API_TOKEN is not None)
def get_cryptobot_display_name(self) -> str:
name = (self.CRYPTOBOT_DISPLAY_NAME or "").strip()
return name if name else "CryptoBot"
def is_heleket_enabled(self) -> bool:
return (
self.HELEKET_ENABLED
@@ -1507,6 +1537,10 @@ class Settings(BaseSettings):
and self.HELEKET_API_KEY is not None
)
def get_heleket_display_name(self) -> str:
name = (self.HELEKET_DISPLAY_NAME or "").strip()
return name if name else "Heleket Crypto"
def is_mulenpay_enabled(self) -> bool:
return (
self.MULENPAY_ENABLED
@@ -1545,6 +1579,10 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def get_pal24_display_name(self) -> str:
name = (self.PAL24_DISPLAY_NAME or "").strip()
return name if name else "PAL24"
def is_platega_enabled(self) -> bool:
return (
self.PLATEGA_ENABLED
@@ -1629,6 +1667,10 @@ class Settings(BaseSettings):
and self.WATA_TERMINAL_PUBLIC_ID is not None
)
def get_wata_display_name(self) -> str:
name = (self.WATA_DISPLAY_NAME or "").strip()
return name if name else "Wata"
def is_cloudpayments_enabled(self) -> bool:
return (
self.CLOUDPAYMENTS_ENABLED
@@ -1636,6 +1678,10 @@ class Settings(BaseSettings):
and self.CLOUDPAYMENTS_API_SECRET is not None
)
def get_cloudpayments_display_name(self) -> str:
name = (self.CLOUDPAYMENTS_DISPLAY_NAME or "").strip()
return name if name else "CloudPayments"
def is_freekassa_enabled(self) -> bool:
return (
self.FREEKASSA_ENABLED
@@ -1652,6 +1698,21 @@ class Settings(BaseSettings):
def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name())
def is_kassa_ai_enabled(self) -> bool:
return (
self.KASSA_AI_ENABLED
and self.KASSA_AI_SHOP_ID is not None
and self.KASSA_AI_API_KEY is not None
and self.KASSA_AI_SECRET_WORD_2 is not None
)
def get_kassa_ai_display_name(self) -> str:
name = (self.KASSA_AI_DISPLAY_NAME or "").strip()
return name if name else "KassaAI"
def get_kassa_ai_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
@@ -1931,7 +1992,11 @@ class Settings(BaseSettings):
def get_stars_rate(self) -> float:
return self.TELEGRAM_STARS_RATE_RUB
def get_telegram_stars_display_name(self) -> str:
name = (self.TELEGRAM_STARS_DISPLAY_NAME or "").strip()
return name if name else "Telegram Stars"
def stars_to_rubles(self, stars: int) -> float:
return stars * self.get_stars_rate()
+161
View File
@@ -0,0 +1,161 @@
"""CRUD операции для платежей KassaAI."""
import json
import logging
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import KassaAiPayment
logger = logging.getLogger(__name__)
async def create_kassa_ai_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = "RUB",
description: Optional[str] = None,
payment_url: Optional[str] = None,
payment_system_id: Optional[int] = None,
expires_at: Optional[datetime] = None,
metadata_json: Optional[str] = None,
) -> KassaAiPayment:
"""Создает запись о платеже KassaAI."""
payment = KassaAiPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_system_id=payment_system_id,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
status="pending",
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f"Создан платеж KassaAI: order_id={order_id}, user_id={user_id}")
return payment
async def get_kassa_ai_payment_by_order_id(
db: AsyncSession, order_id: str
) -> Optional[KassaAiPayment]:
"""Получает платеж по order_id."""
result = await db.execute(
select(KassaAiPayment).where(KassaAiPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_kassa_ai_payment_by_external_order_id(
db: AsyncSession, kassa_ai_order_id: str
) -> Optional[KassaAiPayment]:
"""Получает платеж по ID от KassaAI (orderId)."""
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.kassa_ai_order_id == kassa_ai_order_id
)
)
return result.scalar_one_or_none()
async def get_kassa_ai_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[KassaAiPayment]:
"""Получает платеж по ID."""
result = await db.execute(
select(KassaAiPayment).where(KassaAiPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_kassa_ai_payment_status(
db: AsyncSession,
payment: KassaAiPayment,
*,
status: str,
is_paid: bool = False,
kassa_ai_order_id: Optional[str] = None,
payment_system_id: Optional[int] = None,
callback_payload: Optional[dict] = None,
transaction_id: Optional[int] = None,
) -> KassaAiPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.utcnow()
if kassa_ai_order_id:
payment.kassa_ai_order_id = kassa_ai_order_id
if payment_system_id is not None:
payment.payment_system_id = payment_system_id
if callback_payload:
payment.callback_payload = callback_payload
if transaction_id:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
f"Обновлен статус платежа KassaAI: order_id={payment.order_id}, "
f"status={status}, is_paid={is_paid}"
)
return payment
async def get_pending_kassa_ai_payments(
db: AsyncSession, user_id: int
) -> List[KassaAiPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.user_id == user_id,
KassaAiPayment.status == "pending",
KassaAiPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_user_kassa_ai_payments(
db: AsyncSession,
user_id: int,
limit: int = 10,
offset: int = 0,
) -> List[KassaAiPayment]:
"""Получает платежи пользователя с пагинацией."""
result = await db.execute(
select(KassaAiPayment)
.where(KassaAiPayment.user_id == user_id)
.order_by(KassaAiPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def get_expired_pending_kassa_ai_payments(
db: AsyncSession,
) -> List[KassaAiPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.status == "pending",
KassaAiPayment.is_paid == False,
KassaAiPayment.expires_at < now,
)
)
return list(result.scalars().all())
+17 -1
View File
@@ -797,7 +797,7 @@ async def get_server_ids_by_uuids(
db: AsyncSession,
squad_uuids: List[str]
) -> List[int]:
result = await db.execute(
select(ServerSquad.id)
.where(ServerSquad.squad_uuid.in_(squad_uuids))
@@ -805,6 +805,22 @@ async def get_server_ids_by_uuids(
return [row[0] for row in result.fetchall()]
async def get_server_squads_by_uuids(
db: AsyncSession,
squad_uuids: List[str]
) -> List[ServerSquad]:
"""Получает список ServerSquad объектов по их UUID с загрузкой allowed_promo_groups."""
if not squad_uuids:
return []
result = await db.execute(
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.squad_uuid.in_(squad_uuids))
)
return list(result.scalars().all())
async def ensure_servers_synced(db: AsyncSession) -> None:
"""
Проверяет и синхронизирует серверы при запуске.
+62 -4
View File
@@ -1081,12 +1081,61 @@ async def get_server_monthly_price(
async def get_servers_monthly_prices(
db: AsyncSession,
server_squad_ids: List[int]
server_squad_ids: List[int],
*,
user: Optional["User"] = None,
) -> List[int]:
"""Получает месячные цены серверов с проверкой доступности для промогруппы пользователя."""
from app.database.models import ServerSquad
from sqlalchemy.orm import selectinload
prices = []
# Загружаем промогруппы пользователя если нужно
user_promo_group = None
user_promo_group_id = None
if user:
try:
# Пробуем загрузить промогруппы если ещё не загружены
await db.refresh(user, ["user_promo_groups", "promo_group"])
except Exception:
pass
try:
user_promo_group = user.get_primary_promo_group()
user_promo_group_id = user_promo_group.id if user_promo_group else None
except Exception as e:
logger.warning(f"Не удалось получить промогруппу пользователя: {e}")
for server_id in server_squad_ids:
price = await get_server_monthly_price(db, server_id)
prices.append(price)
# Загружаем сервер с промогруппами
result = await db.execute(
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.id == server_id)
)
server = result.scalar_one_or_none()
if not server:
prices.append(0)
continue
# Проверяем доступность сервера для промогруппы пользователя
is_allowed = True
if user_promo_group_id is not None and server.allowed_promo_groups:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
is_allowed = user_promo_group_id in allowed_ids
if server.is_available and is_allowed:
prices.append(server.price_kopeks)
else:
# Сервер недоступен для промогруппы пользователя
logger.warning(
f"⚠️ Сервер {server.display_name} (id={server_id}) недоступен для "
f"промогруппы пользователя (promo_group_id={user_promo_group_id}), "
f"allowed_promo_groups={[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else []}"
)
prices.append(server.price_kopeks) # Всё равно берём реальную цену
return prices
def _get_discount_percent(
@@ -1146,7 +1195,7 @@ async def calculate_subscription_total_cost(
total_traffic_price = discounted_traffic_per_month * months_in_period
total_traffic_discount = traffic_discount_per_month * months_in_period
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
servers_prices = await get_servers_monthly_prices(db, server_squad_ids, user=user)
servers_price_per_month = sum(servers_prices)
servers_discount_percent = _get_discount_percent(
user,
@@ -1557,6 +1606,15 @@ async def check_and_update_subscription_status(
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
# Детальное логирование для отладки проблемы с деактивацией
time_diff = current_time - subscription.end_date
logger.warning(
f"⏰ DEACTIVATION: подписка {subscription.id} (user_id={subscription.user_id}) "
f"деактивируется в check_and_update_subscription_status. "
f"end_date={subscription.end_date}, current_time={current_time}, "
f"просрочена на {time_diff}"
)
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
+253
View File
@@ -0,0 +1,253 @@
"""CRUD operations for TicketNotification."""
import logging
from datetime import datetime
from typing import List, Optional
from sqlalchemy import select, func, desc, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import TicketNotification, Ticket, User
logger = logging.getLogger(__name__)
class TicketNotificationCRUD:
"""CRUD operations for ticket notifications in cabinet."""
@staticmethod
async def get_by_id(db: AsyncSession, notification_id: int) -> Optional[TicketNotification]:
"""Get notification by ID."""
query = select(TicketNotification).where(TicketNotification.id == notification_id)
result = await db.execute(query)
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
ticket_id: int,
user_id: int,
notification_type: str,
message: Optional[str] = None,
is_for_admin: bool = False,
) -> TicketNotification:
"""Create a new ticket notification."""
notification = TicketNotification(
ticket_id=ticket_id,
user_id=user_id,
notification_type=notification_type,
message=message,
is_for_admin=is_for_admin,
is_read=False,
created_at=datetime.utcnow(),
)
db.add(notification)
await db.commit()
await db.refresh(notification)
return notification
@staticmethod
async def get_user_notifications(
db: AsyncSession,
user_id: int,
unread_only: bool = False,
limit: int = 50,
offset: int = 0,
) -> List[TicketNotification]:
"""Get notifications for a user (not admin)."""
query = (
select(TicketNotification)
.where(
TicketNotification.user_id == user_id,
TicketNotification.is_for_admin == False,
)
.options(selectinload(TicketNotification.ticket))
.order_by(desc(TicketNotification.created_at))
)
if unread_only:
query = query.where(TicketNotification.is_read == False)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@staticmethod
async def get_admin_notifications(
db: AsyncSession,
unread_only: bool = False,
limit: int = 50,
offset: int = 0,
) -> List[TicketNotification]:
"""Get notifications for admins."""
query = (
select(TicketNotification)
.where(TicketNotification.is_for_admin == True)
.options(selectinload(TicketNotification.ticket))
.order_by(desc(TicketNotification.created_at))
)
if unread_only:
query = query.where(TicketNotification.is_read == False)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@staticmethod
async def count_unread_user(db: AsyncSession, user_id: int) -> int:
"""Count unread notifications for a user."""
query = select(func.count()).select_from(TicketNotification).where(
TicketNotification.user_id == user_id,
TicketNotification.is_for_admin == False,
TicketNotification.is_read == False,
)
result = await db.execute(query)
return result.scalar() or 0
@staticmethod
async def count_unread_admin(db: AsyncSession) -> int:
"""Count unread notifications for admins."""
query = select(func.count()).select_from(TicketNotification).where(
TicketNotification.is_for_admin == True,
TicketNotification.is_read == False,
)
result = await db.execute(query)
return result.scalar() or 0
@staticmethod
async def mark_as_read(db: AsyncSession, notification_id: int) -> bool:
"""Mark a notification as read."""
query = (
update(TicketNotification)
.where(TicketNotification.id == notification_id)
.values(is_read=True, read_at=datetime.utcnow())
)
result = await db.execute(query)
await db.commit()
return result.rowcount > 0
@staticmethod
async def mark_all_as_read_user(db: AsyncSession, user_id: int) -> int:
"""Mark all notifications as read for a user."""
query = (
update(TicketNotification)
.where(
TicketNotification.user_id == user_id,
TicketNotification.is_for_admin == False,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
)
result = await db.execute(query)
await db.commit()
return result.rowcount
@staticmethod
async def mark_all_as_read_admin(db: AsyncSession) -> int:
"""Mark all admin notifications as read."""
query = (
update(TicketNotification)
.where(
TicketNotification.is_for_admin == True,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
)
result = await db.execute(query)
await db.commit()
return result.rowcount
@staticmethod
async def mark_ticket_notifications_as_read(
db: AsyncSession, ticket_id: int, user_id: int, is_admin: bool = False
) -> int:
"""Mark all notifications for a specific ticket as read."""
query = (
update(TicketNotification)
.where(
TicketNotification.ticket_id == ticket_id,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
)
if is_admin:
query = query.where(TicketNotification.is_for_admin == True)
else:
query = query.where(
TicketNotification.user_id == user_id,
TicketNotification.is_for_admin == False,
)
result = await db.execute(query)
await db.commit()
return result.rowcount
@staticmethod
async def create_admin_notification_for_new_ticket(
db: AsyncSession, ticket: Ticket
) -> Optional[TicketNotification]:
"""Create notification for admins about new ticket."""
from app.services.support_settings_service import SupportSettingsService
if not SupportSettingsService.get_cabinet_admin_notifications_enabled():
return None
title = (ticket.title or "").strip()[:50]
message = f"Новый тикет #{ticket.id}: {title}"
return await TicketNotificationCRUD.create(
db=db,
ticket_id=ticket.id,
user_id=ticket.user_id,
notification_type="new_ticket",
message=message,
is_for_admin=True,
)
@staticmethod
async def create_user_notification_for_admin_reply(
db: AsyncSession, ticket: Ticket, reply_preview: str
) -> Optional[TicketNotification]:
"""Create notification for user about admin reply."""
from app.services.support_settings_service import SupportSettingsService
if not SupportSettingsService.get_cabinet_user_notifications_enabled():
return None
preview = (reply_preview or "").strip()[:100]
message = f"Ответ на тикет #{ticket.id}: {preview}..."
return await TicketNotificationCRUD.create(
db=db,
ticket_id=ticket.id,
user_id=ticket.user_id,
notification_type="admin_reply",
message=message,
is_for_admin=False,
)
@staticmethod
async def create_admin_notification_for_user_reply(
db: AsyncSession, ticket: Ticket, reply_preview: str
) -> Optional[TicketNotification]:
"""Create notification for admins about user reply."""
from app.services.support_settings_service import SupportSettingsService
if not SupportSettingsService.get_cabinet_admin_notifications_enabled():
return None
preview = (reply_preview or "").strip()[:100]
message = f"Ответ в тикете #{ticket.id}: {preview}..."
return await TicketNotificationCRUD.create(
db=db,
ticket_id=ticket.id,
user_id=ticket.user_id,
notification_type="user_reply",
message=message,
is_for_admin=True,
)
+28 -6
View File
@@ -9,6 +9,23 @@ from app.database.models import Transaction, TransactionType, PaymentMethod, Use
logger = logging.getLogger(__name__)
# Реальные платёжные методы для подсчёта дохода
# Исключены: MANUAL (админские), BALANCE (оплата с баланса), NULL (колесо, промокоды, бонусы)
REAL_PAYMENT_METHODS = [
PaymentMethod.TELEGRAM_STARS.value,
PaymentMethod.TRIBUTE.value,
PaymentMethod.YOOKASSA.value,
PaymentMethod.CRYPTOBOT.value,
PaymentMethod.HELEKET.value,
PaymentMethod.MULENPAY.value,
PaymentMethod.PAL24.value,
PaymentMethod.WATA.value,
PaymentMethod.PLATEGA.value,
PaymentMethod.CLOUDPAYMENTS.value,
PaymentMethod.FREEKASSA.value,
PaymentMethod.KASSA_AI.value,
]
async def create_transaction(
db: AsyncSession,
@@ -209,6 +226,7 @@ async def get_transactions_statistics(
if not end_date:
end_date = datetime.utcnow()
# Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения)
income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
@@ -216,7 +234,8 @@ async def get_transactions_statistics(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
Transaction.created_at <= end_date,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS)
)
)
)
@@ -297,13 +316,15 @@ async def get_transactions_statistics(
)
transactions_today = today_result.scalar()
# Доход за сегодня - только реальные платежи
today_income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= today
Transaction.created_at >= today,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS)
)
)
)
@@ -333,9 +354,9 @@ async def get_revenue_by_period(
db: AsyncSession,
days: int = 30
) -> List[dict]:
"""Доход по дням - только реальные платежи."""
start_date = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(
func.date(Transaction.created_at).label('date'),
@@ -345,13 +366,14 @@ async def get_revenue_by_period(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date
Transaction.created_at >= start_date,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS)
)
)
.group_by(func.date(Transaction.created_at))
.order_by(func.date(Transaction.created_at))
)
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
+1 -1
View File
@@ -564,7 +564,7 @@ async def subtract_user_balance(
rollback_error,
)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
logger.info(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
except Exception as e:
+4 -4
View File
@@ -40,10 +40,10 @@ if IS_SQLITE:
else:
poolclass = AsyncAdaptedQueuePool
pool_kwargs = {
"pool_size": 20,
"max_overflow": 30,
"pool_size": 30, # Увеличен с 20
"max_overflow": 50, # Увеличен с 30
"pool_timeout": 30,
"pool_recycle": 3600,
"pool_recycle": 1800, # Уменьшен с 3600 до 30 мин для более быстрого recycling
"pool_pre_ping": True,
# Агрессивная очистка мертвых соединений
"pool_reset_on_return": "rollback",
@@ -62,7 +62,7 @@ _pg_connect_args = {
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
"timeout": 30, # Увеличен с 10 до 30 сек для высокой нагрузки
}
engine = create_async_engine(
+103
View File
@@ -108,6 +108,7 @@ class PaymentMethod(Enum):
PLATEGA = "platega"
CLOUDPAYMENTS = "cloudpayments"
FREEKASSA = "freekassa"
KASSA_AI = "kassa_ai"
MANUAL = "manual"
BALANCE = "balance"
@@ -649,6 +650,74 @@ class FreekassaPayment(Base):
)
class KassaAiPayment(Base):
"""Платежи через KassaAI (api.fk.life)."""
__tablename__ = "kassa_ai_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
kassa_ai_order_id = Column(String(64), unique=True, nullable=True, index=True) # orderId от KassaAI
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default="pending") # pending, success, failed, expired
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_system_id = Column(Integer, nullable=True) # ID платежной системы (44=СБП, 36=Карты, 43=SberPay)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
# Relationships
user = relationship("User", backref="kassa_ai_payments")
transaction = relationship("Transaction", backref="kassa_ai_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_success(self) -> bool:
return self.status == "success" and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ["failed", "expired"]
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<KassaAiPayment(id={0}, order_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.order_id,
self.amount_rubles,
self.status,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
@@ -2563,3 +2632,37 @@ class WheelSpin(Base):
def __repr__(self) -> str:
return f"<WheelSpin id={self.id} user_id={self.user_id} prize='{self.prize_display_name}'>"
class TicketNotification(Base):
"""Уведомления о тикетах для кабинета (веб-интерфейс)."""
__tablename__ = "ticket_notifications"
__table_args__ = (
Index("ix_ticket_notifications_user_read", "user_id", "is_read"),
Index("ix_ticket_notifications_admin_read", "is_for_admin", "is_read"),
)
id = Column(Integer, primary_key=True, index=True)
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
# Тип уведомления: new_ticket, admin_reply, user_reply
notification_type = Column(String(50), nullable=False)
# Текст уведомления
message = Column(Text, nullable=True)
# Для админа или для пользователя
is_for_admin = Column(Boolean, default=False, nullable=False)
# Прочитано ли уведомление
is_read = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=func.now())
read_at = Column(DateTime, nullable=True)
ticket = relationship("Ticket", backref="notifications")
user = relationship("User", backref="ticket_notifications")
def __repr__(self) -> str:
return f"<TicketNotification id={self.id} type={self.notification_type} for_admin={self.is_for_admin}>"
+119
View File
@@ -1401,6 +1401,118 @@ async def create_freekassa_payments_table():
return False
async def create_kassa_ai_payments_table():
"""Создаёт таблицу kassa_ai_payments для платежей через KassaAI."""
table_exists = await check_table_exists('kassa_ai_payments')
if table_exists:
logger.info("Таблица kassa_ai_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE kassa_ai_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
kassa_ai_order_id VARCHAR(64) NULL UNIQUE,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_paid BOOLEAN NOT NULL DEFAULT 0,
payment_url TEXT NULL,
payment_system_id INTEGER NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
transaction_id INTEGER NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_kassa_ai_user_id ON kassa_ai_payments(user_id);
CREATE UNIQUE INDEX idx_kassa_ai_order_id ON kassa_ai_payments(order_id);
CREATE UNIQUE INDEX idx_kassa_ai_kai_order_id ON kassa_ai_payments(kassa_ai_order_id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE kassa_ai_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
order_id VARCHAR(64) NOT NULL UNIQUE,
kassa_ai_order_id VARCHAR(64) NULL UNIQUE,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
payment_url TEXT NULL,
payment_system_id INTEGER NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
paid_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
transaction_id INTEGER NULL REFERENCES transactions(id)
);
CREATE INDEX idx_kassa_ai_user_id ON kassa_ai_payments(user_id);
CREATE UNIQUE INDEX idx_kassa_ai_order_id ON kassa_ai_payments(order_id);
CREATE UNIQUE INDEX idx_kassa_ai_kai_order_id ON kassa_ai_payments(kassa_ai_order_id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE kassa_ai_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
kassa_ai_order_id VARCHAR(64) NULL UNIQUE,
amount_kopeks INT NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_paid BOOLEAN NOT NULL DEFAULT 0,
payment_url TEXT NULL,
payment_system_id INT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
transaction_id INT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_kassa_ai_user_id ON kassa_ai_payments(user_id);
CREATE UNIQUE INDEX idx_kassa_ai_order_id ON kassa_ai_payments(order_id);
CREATE UNIQUE INDEX idx_kassa_ai_kai_order_id ON kassa_ai_payments(kassa_ai_order_id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы kassa_ai_payments: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица kassa_ai_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы kassa_ai_payments: {e}")
return False
async def create_discount_offers_table():
table_exists = await check_table_exists('discount_offers')
if table_exists:
@@ -6333,6 +6445,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей Freekassa payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ KASSA_AI ===")
kassa_ai_created = await create_kassa_ai_payments_table()
if kassa_ai_created:
logger.info("✅ Таблица KassaAI payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей KassaAI payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
discount_created = await create_discount_offers_table()
if discount_created:
+1 -1
View File
@@ -431,7 +431,7 @@ class RemnaWaveAPI:
data['telegramId'] = telegram_id
if email:
data['email'] = email
if hwid_device_limit:
if hwid_device_limit is not None:
data['hwidDeviceLimit'] = hwid_device_limit
if description:
data['description'] = description
+2 -2
View File
@@ -194,8 +194,8 @@ async def manual_start_round(
return
# Проверяем, есть ли уже активный раунд для этого шаблона
from app.database.crud.contest import get_active_rounds
exists = await get_active_rounds(db, tpl.id)
from app.database.crud.contest import get_active_round_by_template
exists = await get_active_round_by_template(db, tpl.id)
if exists:
await callback.answer(texts.t("ADMIN_ROUND_ALREADY_ACTIVE", "Раунд уже активен."), show_alert=True)
await show_daily_contest(callback, db_user, db)
+313
View File
@@ -1794,5 +1794,318 @@ async def process_notification_value_input(message: Message, state: FSMContext):
await state.clear()
# ============== Настройки мониторинга трафика ==============
def _format_traffic_toggle(enabled: bool) -> str:
return "🟢 Вкл" if enabled else "🔴 Выкл"
def _build_traffic_settings_keyboard() -> InlineKeyboardMarkup:
"""Строит клавиатуру настроек мониторинга трафика."""
fast_enabled = settings.TRAFFIC_FAST_CHECK_ENABLED
daily_enabled = settings.TRAFFIC_DAILY_CHECK_ENABLED
fast_interval = settings.TRAFFIC_FAST_CHECK_INTERVAL_MINUTES
fast_threshold = settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB
daily_time = settings.TRAFFIC_DAILY_CHECK_TIME
daily_threshold = settings.TRAFFIC_DAILY_THRESHOLD_GB
cooldown = settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"{_format_traffic_toggle(fast_enabled)} Быстрая проверка",
callback_data="admin_traffic_toggle_fast"
)],
[InlineKeyboardButton(
text=f"⏱ Интервал: {fast_interval} мин",
callback_data="admin_traffic_edit_fast_interval"
)],
[InlineKeyboardButton(
text=f"📊 Порог дельты: {fast_threshold} ГБ",
callback_data="admin_traffic_edit_fast_threshold"
)],
[InlineKeyboardButton(
text=f"{_format_traffic_toggle(daily_enabled)} Суточная проверка",
callback_data="admin_traffic_toggle_daily"
)],
[InlineKeyboardButton(
text=f"🕐 Время проверки: {daily_time}",
callback_data="admin_traffic_edit_daily_time"
)],
[InlineKeyboardButton(
text=f"📈 Суточный порог: {daily_threshold} ГБ",
callback_data="admin_traffic_edit_daily_threshold"
)],
[InlineKeyboardButton(
text=f"⏳ Кулдаун: {cooldown} мин",
callback_data="admin_traffic_edit_cooldown"
)],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")],
])
def _build_traffic_settings_text() -> str:
"""Строит текст настроек мониторинга трафика."""
fast_enabled = settings.TRAFFIC_FAST_CHECK_ENABLED
daily_enabled = settings.TRAFFIC_DAILY_CHECK_ENABLED
fast_status = _format_traffic_toggle(fast_enabled)
daily_status = _format_traffic_toggle(daily_enabled)
text = (
"⚙️ <b>Настройки мониторинга трафика</b>\n\n"
f"<b>Быстрая проверка:</b> {fast_status}\n"
f"• Интервал: {settings.TRAFFIC_FAST_CHECK_INTERVAL_MINUTES} мин\n"
f"• Порог дельты: {settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB} ГБ\n\n"
f"<b>Суточная проверка:</b> {daily_status}\n"
f"• Время: {settings.TRAFFIC_DAILY_CHECK_TIME} UTC\n"
f"• Порог: {settings.TRAFFIC_DAILY_THRESHOLD_GB} ГБ\n\n"
f"<b>Общие:</b>\n"
f"• Кулдаун уведомлений: {settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES} мин\n"
)
# Информация о фильтрах
monitored_nodes = settings.get_traffic_monitored_nodes()
ignored_nodes = settings.get_traffic_ignored_nodes()
excluded_uuids = settings.get_traffic_excluded_user_uuids()
if monitored_nodes:
text += f"• Мониторим только: {len(monitored_nodes)} нод(ы)\n"
if ignored_nodes:
text += f"• Игнорируем: {len(ignored_nodes)} нод(ы)\n"
if excluded_uuids:
text += f"• Исключено юзеров: {len(excluded_uuids)}\n"
return text
@router.callback_query(F.data == "admin_mon_traffic_settings")
@admin_required
async def admin_traffic_settings(callback: CallbackQuery):
"""Показывает настройки мониторинга трафика."""
try:
text = _build_traffic_settings_text()
keyboard = _build_traffic_settings_keyboard()
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка отображения настроек трафика: {e}")
await callback.answer("❌ Ошибка загрузки настроек", show_alert=True)
@router.callback_query(F.data == "admin_traffic_toggle_fast")
@admin_required
async def toggle_fast_check(callback: CallbackQuery):
"""Переключает быструю проверку трафика."""
try:
from app.services.system_settings_service import BotConfigurationService
current = settings.TRAFFIC_FAST_CHECK_ENABLED
new_value = not current
async with AsyncSessionLocal() as db:
await BotConfigurationService.set_value(db, "TRAFFIC_FAST_CHECK_ENABLED", new_value)
await db.commit()
await callback.answer("✅ Включено" if new_value else "⏸️ Отключено")
# Обновляем отображение
text = _build_traffic_settings_text()
keyboard = _build_traffic_settings_keyboard()
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка переключения быстрой проверки: {e}")
await callback.answer("❌ Ошибка", show_alert=True)
@router.callback_query(F.data == "admin_traffic_toggle_daily")
@admin_required
async def toggle_daily_check(callback: CallbackQuery):
"""Переключает суточную проверку трафика."""
try:
from app.services.system_settings_service import BotConfigurationService
current = settings.TRAFFIC_DAILY_CHECK_ENABLED
new_value = not current
async with AsyncSessionLocal() as db:
await BotConfigurationService.set_value(db, "TRAFFIC_DAILY_CHECK_ENABLED", new_value)
await db.commit()
await callback.answer("✅ Включено" if new_value else "⏸️ Отключено")
text = _build_traffic_settings_text()
keyboard = _build_traffic_settings_keyboard()
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка переключения суточной проверки: {e}")
await callback.answer("❌ Ошибка", show_alert=True)
@router.callback_query(F.data == "admin_traffic_edit_fast_interval")
@admin_required
async def edit_fast_interval(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование интервала быстрой проверки."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_FAST_CHECK_INTERVAL_MINUTES",
traffic_setting_type="int",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"⏱ Введите интервал быстрой проверки в минутах (минимум 1):"
)
@router.callback_query(F.data == "admin_traffic_edit_fast_threshold")
@admin_required
async def edit_fast_threshold(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование порога быстрой проверки."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_FAST_CHECK_THRESHOLD_GB",
traffic_setting_type="float",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"📊 Введите порог дельты трафика в ГБ (например: 5.0):"
)
@router.callback_query(F.data == "admin_traffic_edit_daily_time")
@admin_required
async def edit_daily_time(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование времени суточной проверки."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_DAILY_CHECK_TIME",
traffic_setting_type="time",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"🕐 Введите время суточной проверки в формате HH:MM (UTC):\n"
"Например: 00:00, 03:00, 12:30"
)
@router.callback_query(F.data == "admin_traffic_edit_daily_threshold")
@admin_required
async def edit_daily_threshold(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование суточного порога."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_DAILY_THRESHOLD_GB",
traffic_setting_type="float",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"📈 Введите суточный порог трафика в ГБ (например: 50.0):"
)
@router.callback_query(F.data == "admin_traffic_edit_cooldown")
@admin_required
async def edit_cooldown(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование кулдауна уведомлений."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES",
traffic_setting_type="int",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"⏳ Введите кулдаун уведомлений в минутах (минимум 1):"
)
@router.message(AdminStates.editing_traffic_setting)
async def process_traffic_setting_input(message: Message, state: FSMContext):
"""Обрабатывает ввод настройки мониторинга трафика."""
from app.services.system_settings_service import BotConfigurationService
data = await state.get_data()
if not data:
await state.clear()
await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.")
return
raw_value = (message.text or "").strip()
setting_key = data.get("traffic_setting_key")
setting_type = data.get("traffic_setting_type")
# Валидация и парсинг значения
try:
if setting_type == "int":
value = int(raw_value)
if value < 1:
raise ValueError("Значение должно быть >= 1")
elif setting_type == "float":
value = float(raw_value.replace(",", "."))
if value <= 0:
raise ValueError("Значение должно быть > 0")
elif setting_type == "time":
# Валидация формата HH:MM
import re
if not re.match(r"^\d{1,2}:\d{2}$", raw_value):
raise ValueError("Неверный формат времени. Используйте HH:MM")
parts = raw_value.split(":")
hours, minutes = int(parts[0]), int(parts[1])
if hours < 0 or hours > 23 or minutes < 0 or minutes > 59:
raise ValueError("Неверное время")
value = f"{hours:02d}:{minutes:02d}"
else:
value = raw_value
except ValueError as e:
await message.answer(f"{str(e)}")
return
# Сохраняем значение
try:
async with AsyncSessionLocal() as db:
await BotConfigurationService.set_value(db, setting_key, value)
await db.commit()
back_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="⬅️ К настройкам трафика", callback_data="admin_mon_traffic_settings")]
]
)
await message.answer("✅ Настройка сохранена!", reply_markup=back_keyboard)
# Обновляем исходное сообщение с настройками
chat_id = data.get("settings_message_chat")
message_id = data.get("settings_message_id")
if chat_id and message_id:
try:
text = _build_traffic_settings_text()
keyboard = _build_traffic_settings_keyboard()
await message.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=text,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception:
pass # Игнорируем если сообщение уже удалено
except Exception as e:
logger.error(f"Ошибка сохранения настройки трафика: {e}")
await message.answer(f"❌ Ошибка сохранения: {str(e)}")
await state.clear()
def register_handlers(dp):
dp.include_router(router)
+391
View File
@@ -0,0 +1,391 @@
"""Handler for KassaAI balance top-up."""
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
async def _create_kassa_ai_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating KassaAI payment and sending response.
Args:
message_or_callback: Either a Message or CallbackQuery object
db_user: User object
db: Database session
amount_kopeks: Amount in kopeks
edit_message: Whether to edit existing message or send new one
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description="Пополнение баланса",
)
result = await payment_service.create_kassa_ai_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, "email", None),
language=db_user.language,
)
if not result:
error_text = texts.t(
"PAYMENT_CREATE_ERROR",
"Не удалось создать платёж. Попробуйте позже.",
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML",
)
else:
await message_or_callback.answer(
error_text,
parse_mode="HTML",
)
return
payment_url = result.get("payment_url")
display_name = settings.get_kassa_ai_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"PAY_BUTTON",
"💳 Оплатить {amount}",
).format(amount=f"{amount_rub:.0f}"),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
],
]
)
response_text = texts.t(
"KASSA_AI_PAYMENT_CREATED",
"💳 <b>Оплата через {name}</b>\n\n"
"Сумма: <b>{amount}₽</b>\n\n"
"Нажмите кнопку ниже для оплаты.\n"
"После успешной оплаты баланс будет пополнен автоматически.",
).format(name=display_name, amount=f"{amount_rub:.2f}")
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode="HTML",
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"KassaAI payment created: user=%s, amount=%s",
db_user.telegram_id,
amount_rub,
)
@error_handler
async def process_kassa_ai_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
"""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, "restriction_topup", False):
reason = (
getattr(db_user, "restriction_reason", None)
or "Действие ограничено администратором"
)
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append(
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
)
keyboard.append(
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
)
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await state.clear()
return
# Validate amount
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
"PAYMENT_AMOUNT_TOO_LOW",
"Минимальная сумма пополнения: {min_amount}",
).format(min_amount=min_amount // 100),
parse_mode="HTML",
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
"PAYMENT_AMOUNT_TOO_HIGH",
"Максимальная сумма пополнения: {max_amount}",
).format(max_amount=max_amount // 100),
parse_mode="HTML",
)
return
await state.clear()
await _create_kassa_ai_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_kassa_ai_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start KassaAI top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, "restriction_topup", False):
reason = (
getattr(db_user, "restriction_reason", None)
or "Действие ограничено администратором"
)
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append(
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
)
keyboard.append(
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
)
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="kassa_ai")
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_kassa_ai_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
]
]
)
await callback.message.edit_text(
texts.t(
"KASSA_AI_ENTER_AMOUNT",
"💳 <b>Пополнение через {name}</b>\n\n"
"Введите сумму пополнения в рублях.\n\n"
"Минимум: {min_amount}\n"
"Максимум: {max_amount}",
).format(
name=display_name,
min_amount=min_amount,
max_amount=f"{max_amount:,}".replace(",", " "),
),
parse_mode="HTML",
reply_markup=keyboard,
)
@error_handler
async def process_kassa_ai_custom_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process custom amount input for KassaAI payment.
"""
data = await state.get_data()
if data.get("payment_method") != "kassa_ai":
return
texts = get_texts(db_user.language)
try:
amount_text = message.text.replace(",", ".").replace(" ", "").strip()
amount_rubles = float(amount_text)
amount_kopeks = int(amount_rubles * 100)
except (ValueError, TypeError):
await message.answer(
texts.t(
"PAYMENT_INVALID_AMOUNT",
"Введите корректную сумму числом.",
),
parse_mode="HTML",
)
return
await process_kassa_ai_payment_amount(
message=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
state=state,
)
@error_handler
async def process_kassa_ai_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for KassaAI payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_kassa_ai_enabled():
await callback.answer(
texts.t("KASSA_AI_NOT_AVAILABLE", "KassaAI временно недоступен"),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|kassa_ai|{amount_kopeks}
try:
parts = callback.data.split("|")
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer("Invalid callback data", show_alert=True)
return
except (ValueError, IndexError):
await callback.answer("Invalid amount", show_alert=True)
return
# Проверка ограничения на пополнение
if getattr(db_user, "restriction_topup", False):
reason = (
getattr(db_user, "restriction_reason", None)
or "Действие ограничено администратором"
)
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append(
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
)
keyboard.append(
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
)
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
# Validate amount
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_kassa_ai_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
+16
View File
@@ -118,6 +118,12 @@ async def route_payment_by_method(
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "kassa_ai":
from .kassa_ai import process_kassa_ai_payment_amount
async with AsyncSessionLocal() as db:
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
return True
return False
@@ -990,6 +996,16 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("topup_amount|freekassa|")
)
from .kassa_ai import start_kassa_ai_topup, process_kassa_ai_quick_amount
dp.callback_query.register(
start_kassa_ai_topup,
F.data == "topup_kassa_ai"
)
dp.callback_query.register(
process_kassa_ai_quick_amount,
F.data.startswith("topup_amount|kassa_ai|")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
+66 -20
View File
@@ -48,6 +48,10 @@ from app.utils.promo_offer import (
)
from app.utils.timezone import format_local_datetime
from app.database.crud.user_message import get_random_active_message
from app.middlewares.channel_checker import (
get_pending_payload_from_redis,
delete_pending_payload_from_redis,
)
from app.database.crud.subscription import decrement_subscription_server_counts
from app.services.blacklist_service import blacklist_service
@@ -327,6 +331,19 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
campaign_notification_sent = data.pop("campaign_notification_sent", False)
state_needs_update = had_pending_payload or had_campaign_notification_flag
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(message.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
state_needs_update = True
logger.info(
"📦 START: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
)
# Очищаем Redis после получения
await delete_pending_payload_from_redis(message.from_user.id)
referral_code = None
campaign = None
start_args = message.text.split()
@@ -1832,6 +1849,17 @@ async def required_sub_channel_check(
state_data = await state.get_data() or {}
pending_start_payload = state_data.pop("pending_start_payload", None)
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(query.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
logger.info(
"📦 CHANNEL CHECK: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
)
state_updated = pending_start_payload is not None
if pending_start_payload:
@@ -1840,27 +1868,27 @@ async def required_sub_channel_check(
pending_start_payload,
)
if "campaign_id" not in state_data and "referral_code" not in state_data:
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
# Очищаем Redis после получения payload
await delete_pending_payload_from_redis(query.from_user.id)
if campaign:
state_data["campaign_id"] = campaign.id
logger.info(
"📣 CHANNEL CHECK: Кампания %s восстановлена из payload",
campaign.id,
)
else:
state_data["referral_code"] = pending_start_payload
logger.info(
"🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код",
)
# Всегда обновляем referral_code если есть новый payload
# (исправление бага с устаревшими данными в state)
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
if campaign:
state_data["campaign_id"] = campaign.id
logger.info(
"📣 CHANNEL CHECK: Кампания %s восстановлена из payload",
campaign.id,
)
else:
logger.debug(
"️ CHANNEL CHECK: Payload уже обработан ранее, пропускаем восстановление",
state_data["referral_code"] = pending_start_payload
logger.info(
"🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код",
)
if state_updated:
@@ -1986,9 +2014,18 @@ async def required_sub_channel_check(
await state.set_data(state_data)
if settings.SKIP_RULES_ACCEPT:
if settings.SKIP_REFERRAL_CODE:
if settings.SKIP_REFERRAL_CODE or state_data.get('referral_code'):
from app.utils.user_utils import generate_unique_referral_code
# Проверяем реферальный код из ссылки
referrer_id = None
ref_code_from_link = state_data.get('referral_code')
if ref_code_from_link:
referrer = await get_user_by_referral_code(db, ref_code_from_link)
if referrer:
referrer_id = referrer.id
logger.info(f"✅ CHANNEL CHECK: Реферер найден из ссылки: {referrer.id}")
referral_code = await generate_unique_referral_code(db, query.from_user.id)
user = await create_user(
@@ -1999,9 +2036,18 @@ async def required_sub_channel_check(
last_name=query.from_user.last_name,
language=language,
referral_code=referral_code,
referred_by_id=referrer_id,
)
await db.refresh(user, ['subscription'])
# Обрабатываем реферальную регистрацию
if referrer_id:
try:
await process_referral_registration(db, user.id, referrer_id, bot)
logger.info(f"✅ CHANNEL CHECK: Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
# Показываем главное меню после создания пользователя
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
user.subscription
+157 -50
View File
@@ -599,7 +599,18 @@ async def show_trial_offer(
texts = get_texts(db_user.language)
if db_user.subscription or db_user.has_had_paid_subscription:
# Проверяем, использовал ли пользователь триал
# PENDING триальные подписки не считаются - пользователь может повторить оплату
trial_blocked = False
if db_user.has_had_paid_subscription:
trial_blocked = True
elif db_user.subscription:
sub = db_user.subscription
# Разрешаем если это PENDING триальная подписка (повторная попытка оплаты)
if not (sub.status == SubscriptionStatus.PENDING.value and sub.is_trial):
trial_blocked = True
if trial_blocked:
await callback.message.edit_text(
texts.TRIAL_ALREADY_USED,
reply_markup=get_back_keyboard(db_user.language)
@@ -771,6 +782,13 @@ def _get_trial_payment_keyboard(language: str, can_pay_from_balance: bool = Fals
callback_data="trial_payment_wata"
)])
if settings.is_platega_enabled():
platega_name = settings.get_platega_display_name()
keyboard.append([types.InlineKeyboardButton(
text=f"💳 {platega_name}",
callback_data="trial_payment_platega"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
@@ -807,7 +825,18 @@ async def activate_trial(
await callback.answer()
return
if db_user.subscription or db_user.has_had_paid_subscription:
# Проверяем, использовал ли пользователь триал
# PENDING триальные подписки не считаются - пользователь может повторить оплату
trial_blocked = False
if db_user.has_had_paid_subscription:
trial_blocked = True
elif db_user.subscription:
sub = db_user.subscription
# Разрешаем если это PENDING триальная подписка (повторная попытка оплаты)
if not (sub.status == SubscriptionStatus.PENDING.value and sub.is_trial):
trial_blocked = True
if trial_blocked:
await callback.message.edit_text(
texts.TRIAL_ALREADY_USED,
reply_markup=get_back_keyboard(db_user.language)
@@ -1430,6 +1459,13 @@ async def return_to_saved_cart(
texts = get_texts(db_user.language)
# Проверяем режим корзины - если это тарифная корзина, перенаправляем на соответствующий обработчик
cart_mode = cart_data.get('cart_mode')
if cart_mode in ('tariff_purchase', 'daily_tariff_purchase', 'extend') and cart_data.get('tariff_id'):
from .tariff_purchase import return_to_saved_tariff_cart
await return_to_saved_tariff_cart(callback, state, db_user, db, cart_data)
return
preserved_metadata_keys = {
'saved_cart',
'missing_amount',
@@ -2871,19 +2907,19 @@ async def confirm_purchase(
await db.refresh(db_user)
subscription_service = SubscriptionService()
# При покупке подписки ВСЕГДА сбрасываем трафик в панели
if db_user.remnawave_uuid:
remnawave_user = await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка подписки",
)
else:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка подписки",
)
@@ -2892,7 +2928,7 @@ async def confirm_purchase(
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка подписки (повторная попытка)",
)
@@ -3346,7 +3382,17 @@ async def handle_trial_pay_with_balance(
texts = get_texts(db_user.language)
# Проверяем права на триал
if db_user.subscription or db_user.has_had_paid_subscription:
# PENDING триальные подписки не считаются - пользователь может повторить оплату
trial_blocked = False
if db_user.has_had_paid_subscription:
trial_blocked = True
elif db_user.subscription:
sub = db_user.subscription
# Разрешаем если это PENDING триальная подписка (повторная попытка оплаты)
if not (sub.status == SubscriptionStatus.PENDING.value and sub.is_trial):
trial_blocked = True
if trial_blocked:
await callback.message.edit_text(
texts.TRIAL_ALREADY_USED,
reply_markup=get_back_keyboard(db_user.language)
@@ -3681,7 +3727,17 @@ async def handle_trial_payment_method(
texts = get_texts(db_user.language)
# Проверяем права на триал
if db_user.subscription or db_user.has_had_paid_subscription:
# PENDING триальные подписки не считаются - пользователь может повторить оплату
trial_blocked = False
if db_user.has_had_paid_subscription:
trial_blocked = True
elif db_user.subscription:
sub = db_user.subscription
# Разрешаем если это PENDING триальная подписка (повторная попытка оплаты)
if not (sub.status == SubscriptionStatus.PENDING.value and sub.is_trial):
trial_blocked = True
if trial_blocked:
await callback.message.edit_text(
texts.TRIAL_ALREADY_USED,
reply_markup=get_back_keyboard(db_user.language)
@@ -3758,6 +3814,7 @@ async def handle_trial_payment_method(
elif payment_method == "yookassa_sbp":
# Оплата через YooKassa СБП
payment_result = await payment_service.create_yookassa_sbp_payment(
db=db,
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
@@ -3793,11 +3850,12 @@ async def handle_trial_payment_method(
elif payment_method == "yookassa":
# Оплата через YooKassa карта
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
@@ -3825,20 +3883,37 @@ async def handle_trial_payment_method(
elif payment_method == "cryptobot":
# Оплата через CryptoBot
# Конвертируем копейки в USD
from app.utils.currency_converter import currency_converter
try:
usd_rate = await currency_converter.get_usd_to_rub_rate()
except Exception as rate_error:
logger.warning("Не удалось получить курс USD: %s", rate_error)
usd_rate = 95.0
amount_rubles = trial_price_kopeks / 100
amount_usd = round(amount_rubles / usd_rate, 2)
if amount_usd < 1:
amount_usd = 1.0
payment_result = await payment_service.create_cryptobot_payment(
amount_kopeks=trial_price_kopeks,
db=db,
user_id=db_user.id,
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
payload=f"trial_{pending_subscription.id}_{db_user.id}",
)
if not payment_result or not payment_result.get("pay_url"):
payment_url = (
payment_result.get("mini_app_invoice_url")
or payment_result.get("bot_invoice_url")
or payment_result.get("web_app_invoice_url")
) if payment_result else None
if not payment_result or not payment_url:
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
@@ -3850,7 +3925,7 @@ async def handle_trial_payment_method(
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_cryptobot_{pending_subscription.id}"
@@ -3863,19 +3938,16 @@ async def handle_trial_payment_method(
elif payment_method == "heleket":
# Оплата через Heleket
payment_result = await payment_service.create_heleket_payment(
db=db,
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
language=db_user.language,
)
if not payment_result or not payment_result.get("pay_url"):
if not payment_result or not payment_result.get("payment_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
@@ -3887,7 +3959,7 @@ async def handle_trial_payment_method(
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["payment_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_heleket_{pending_subscription.id}"
@@ -3900,19 +3972,16 @@ async def handle_trial_payment_method(
elif payment_method == "mulenpay":
# Оплата через MulenPay
payment_result = await payment_service.create_mulenpay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
language=db_user.language,
)
if not payment_result or not payment_result.get("pay_url"):
if not payment_result or not payment_result.get("payment_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
@@ -3925,7 +3994,7 @@ async def handle_trial_payment_method(
"💰 Сумма: {amount}"
).format(name=mulenpay_name, amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["payment_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_mulenpay_{pending_subscription.id}"
@@ -3938,19 +4007,16 @@ async def handle_trial_payment_method(
elif payment_method == "pal24":
# Оплата через PAL24
payment_result = await payment_service.create_pal24_payment(
db=db,
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
language=db_user.language,
)
if not payment_result or not payment_result.get("pay_url"):
if not payment_result or not payment_result.get("payment_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
@@ -3962,7 +4028,7 @@ async def handle_trial_payment_method(
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["payment_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_pal24_{pending_subscription.id}"
@@ -3975,19 +4041,16 @@ async def handle_trial_payment_method(
elif payment_method == "wata":
# Оплата через WATA
payment_result = await payment_service.create_wata_payment(
db=db,
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
language=db_user.language,
)
if not payment_result or not payment_result.get("pay_url"):
if not payment_result or not payment_result.get("payment_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
@@ -3999,7 +4062,7 @@ async def handle_trial_payment_method(
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["payment_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_wata_{pending_subscription.id}"
@@ -4009,6 +4072,50 @@ async def handle_trial_payment_method(
parse_mode="HTML",
)
elif payment_method == "platega":
# Оплата через Platega
active_methods = settings.get_platega_active_methods()
if not active_methods:
await callback.answer("❌ Platega не настроена", show_alert=True)
return
# Используем первый активный метод
method_code = active_methods[0]
payment_result = await payment_service.create_platega_payment(
db=db,
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
language=db_user.language,
payment_method_code=method_code,
)
if not payment_result or not payment_result.get("redirect_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
platega_name = settings.get_platega_display_name()
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_PLATEGA",
"💳 <b>Оплата через {provider}</b>\n\n"
"Нажмите кнопку ниже для перехода к оплате.\n\n"
"💰 Сумма: {amount}"
).format(provider=platega_name, amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["redirect_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_platega_{pending_subscription.id}"
)],
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
]),
parse_mode="HTML",
)
else:
await callback.answer(f"❌ Неизвестный метод оплаты: {payment_method}", show_alert=True)
return
+216 -7
View File
@@ -621,13 +621,33 @@ async def select_tariff(
)
else:
missing = daily_price - user_balance
# Сохраняем данные корзины для автопокупки суточного тарифа
cart_data = {
'cart_mode': 'daily_tariff_purchase',
'tariff_id': tariff_id,
'is_daily': True,
'daily_price_kopeks': daily_price,
'total_price': daily_price,
'user_id': db_user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Покупка суточного тарифа {tariff.name}",
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
f"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"🔄 Тип: Суточный\n"
f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>\n\n"
f"🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>",
reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
@@ -909,12 +929,13 @@ async def handle_custom_confirm(
)
# Обновляем пользователя в Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка тарифа",
)
except Exception as e:
@@ -1087,15 +1108,35 @@ async def select_tariff_period(
parse_mode="HTML"
)
else:
# Недостаточно средств
# Недостаточно средств - сохраняем корзину для автопокупки
missing = final_price - user_balance
# Сохраняем данные корзины для автопокупки после пополнения
cart_data = {
'cart_mode': 'tariff_purchase',
'tariff_id': tariff_id,
'period_days': period,
'total_price': final_price,
'user_id': db_user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Покупка тарифа {tariff.name} на {period} дней",
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
f"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 Стоимость: {_format_price_kopeks(final_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>\n\n"
f"🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>",
reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language),
parse_mode="HTML"
)
@@ -1188,12 +1229,13 @@ async def confirm_tariff_purchase(
)
# Обновляем пользователя в Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка тарифа",
)
except Exception as e:
@@ -1355,12 +1397,13 @@ async def confirm_daily_tariff_purchase(
await db.refresh(subscription)
# Обновляем пользователя в Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка суточного тарифа",
)
except Exception as e:
@@ -1584,13 +1627,37 @@ async def select_tariff_extend_period(
)
else:
missing = final_price - user_balance
# Получаем текущую подписку для сохранения в корзину
subscription = await get_subscription_by_user_id(db, db_user.id)
# Сохраняем данные корзины для автопокупки после пополнения
cart_data = {
'cart_mode': 'extend',
'tariff_id': tariff_id,
'subscription_id': subscription.id if subscription else None,
'period_days': period,
'total_price': final_price,
'user_id': db_user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Продление тарифа {tariff.name} на {period} дней",
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
f"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>\n\n"
f"🛒 <i>Корзина сохранена! После пополнения баланса подписка будет продлена автоматически.</i>",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text=texts.BACK, callback_data="subscription_extend")]
@@ -3073,6 +3140,148 @@ async def confirm_instant_switch(
await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True)
async def return_to_saved_tariff_cart(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession,
cart_data: dict,
):
"""Восстанавливает сохраненную корзину тарифа после пополнения баланса."""
texts = get_texts(db_user.language)
cart_mode = cart_data.get('cart_mode')
tariff_id = cart_data.get('tariff_id')
if not tariff_id:
await callback.answer("❌ Данные корзины повреждены", show_alert=True)
return
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("❌ Тариф больше недоступен", show_alert=True)
# Очищаем корзину
await user_cart_service.delete_user_cart(db_user.id)
return
total_price = cart_data.get('total_price', 0)
user_balance = db_user.balance_kopeks or 0
traffic = _format_traffic(tariff.traffic_limit_gb)
# Проверяем баланс
if user_balance < total_price:
missing = total_price - user_balance
if cart_mode == 'daily_tariff_purchase':
await callback.message.edit_text(
f"❌ <b>Все еще недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"🔄 Тип: Суточный\n"
f"💰 Стоимость: {_format_price_kopeks(total_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
elif cart_mode == 'extend':
period = cart_data.get('period_days', 30)
await callback.message.edit_text(
f"❌ <b>Все еще недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 Стоимость: {_format_price_kopeks(total_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language),
parse_mode="HTML"
)
else: # tariff_purchase
period = cart_data.get('period_days', 30)
await callback.message.edit_text(
f"❌ <b>Все еще недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 Стоимость: {_format_price_kopeks(total_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language),
parse_mode="HTML"
)
await callback.answer()
return
# Баланс достаточен - показываем подтверждение
discount_percent = cart_data.get('discount_percent', 0)
if cart_mode == 'daily_tariff_purchase':
daily_price = cart_data.get('daily_price_kopeks', total_price)
await callback.message.edit_text(
f"✅ <b>Подтверждение покупки</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"🔄 Тип: Суточный\n"
f"💰 <b>Стоимость в день: {_format_price_kopeks(daily_price)}</b>\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"После оплаты: {_format_price_kopeks(user_balance - daily_price)}",
reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
elif cart_mode == 'extend':
period = cart_data.get('period_days', 30)
discount_text = ""
if discount_percent > 0:
original_price = int(total_price / (1 - discount_percent / 100))
discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(original_price - total_price)})"
await callback.message.edit_text(
f"✅ <b>Подтверждение продления</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"📅 Период: {_format_period(period)}\n"
f"{discount_text}\n"
f"💰 <b>Итого: {_format_price_kopeks(total_price)}</b>\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"После оплаты: {_format_price_kopeks(user_balance - total_price)}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить продление",
callback_data=f"tariff_ext_confirm:{tariff_id}:{period}"
)],
[InlineKeyboardButton(
text=texts.BACK,
callback_data=f"tariff_extend:{tariff_id}"
)]
]),
parse_mode="HTML"
)
else: # tariff_purchase
period = cart_data.get('period_days', 30)
discount_text = ""
if discount_percent > 0:
original_price = int(total_price / (1 - discount_percent / 100))
discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(original_price - total_price)})"
await callback.message.edit_text(
f"✅ <b>Подтверждение покупки</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"📅 Период: {_format_period(period)}\n"
f"{discount_text}\n"
f"💰 <b>Итого: {_format_price_kopeks(total_price)}</b>\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"После оплаты: {_format_price_kopeks(user_balance - total_price)}",
reply_markup=get_tariff_confirm_keyboard(tariff_id, period, db_user.language),
parse_mode="HTML"
)
await callback.answer("✅ Корзина восстановлена!")
def register_tariff_purchase_handlers(dp: Dispatcher):
"""Регистрирует обработчики покупки по тарифам."""
# Список тарифов (для режима tariffs)
+9 -2
View File
@@ -571,11 +571,14 @@ def get_daily_contest_manage_keyboard(
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_MANUAL", "🧪 Ручной старт"), callback_data=f"admin_daily_manual_{template_id}"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE", "🏅 Приз (дни)"), callback_data=f"admin_daily_edit_{template_id}_prize_days"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE_TYPE", "🏅 Тип приза"), callback_data=f"admin_daily_edit_{template_id}_prize_type"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE_VALUE", "💰 Значение приза"), callback_data=f"admin_daily_edit_{template_id}_prize_value"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_ATTEMPTS", "🔁 Попытки"), callback_data=f"admin_daily_edit_{template_id}_attempts_per_user"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_TIMES", "⏰ Раундов/день"), callback_data=f"admin_daily_edit_{template_id}_times_per_day"),
],
[
@@ -1776,6 +1779,10 @@ def get_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(
text=_t(texts, "ADMIN_MONITORING_TEST_NOTIFICATIONS", "🧪 Тест уведомлений"),
callback_data="admin_mon_test_notifications"
),
InlineKeyboardButton(
text="⚙️ Настройки трафика",
callback_data="admin_mon_traffic_settings"
)
],
[
+10
View File
@@ -1471,6 +1471,16 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
has_direct_payment_methods = True
if settings.is_kassa_ai_enabled():
kassa_ai_name = settings.get_kassa_ai_display_name()
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_KASSA_AI", f"💳 {kassa_ai_name}"),
callback_data=_build_callback("kassa_ai")
)
])
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append([
InlineKeyboardButton(
+23
View File
@@ -1187,6 +1187,8 @@
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>SBP (YooKassa)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_HELEKET_MARKUP_LABEL": "Provider markup",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Provider discount",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
@@ -1552,6 +1554,27 @@
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Trial server",
"TRIAL_SERVER_RANDOM_POOL": "🎲 Random choice among {count} servers",
"PAID_TRIAL_HEADER": "⚡ <b>Trial Subscription</b>",
"PAID_TRIAL_SELECT_PAYMENT": "Choose a payment method:",
"PAID_TRIAL_CAN_PAY_BALANCE": "You can pay for the trial from your balance or choose another payment method.",
"PAID_TRIAL_PAYMENT_DESC": "Trial subscription for {days} days",
"PAID_TRIAL_INVOICE_TITLE": "Trial subscription for {days} days",
"PAID_TRIAL_STARS_LABEL": "Trial subscription",
"PAID_TRIAL_STARS_WAITING": "⭐ To pay for the trial subscription, click the payment button in the message above.\n\nAfter successful payment, the subscription will be activated automatically.",
"PAID_TRIAL_YOOKASSA_SBP": "🏦 <b>SBP Payment</b>\n\nScan the QR code or follow the link to pay.\n\n💰 Amount: {amount}",
"PAID_TRIAL_YOOKASSA_CARD": "💳 <b>Card Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_CRYPTOBOT": "🪙 <b>CryptoBot Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_HELEKET": "🪙 <b>Heleket Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_MULENPAY": "💳 <b>{name} Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_PAL24": "💳 <b>PayPalych Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_WATA": "💳 <b>WATA Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_PAY_BALANCE": "💳 Pay from balance",
"PAID_TRIAL_BALANCE_SUCCESS": "✅ Trial subscription paid successfully!\n\nActivation in progress...",
"PERIOD": "Period",
"TRAFFIC": "Traffic",
"DEVICES": "Devices",
"PRICE": "Price",
"YOUR_BALANCE": "Your balance",
"UNBLOCK": "✅ Unblock",
"UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.",
"UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.",
+23
View File
@@ -1204,6 +1204,8 @@
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_HELEKET_MARKUP_LABEL": "Наценка провайдера",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Скидка провайдера",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
@@ -1569,6 +1571,27 @@
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Тестовый сервер",
"TRIAL_SERVER_RANDOM_POOL": "🎲 Случайный из {count} серверов",
"PAID_TRIAL_HEADER": "⚡ <b>Пробная подписка</b>",
"PAID_TRIAL_SELECT_PAYMENT": "Выберите подходящий способ оплаты:",
"PAID_TRIAL_CAN_PAY_BALANCE": "Вы можете оплатить пробную подписку с баланса или выбрать другой способ оплаты.",
"PAID_TRIAL_PAYMENT_DESC": "Пробная подписка на {days} дней",
"PAID_TRIAL_INVOICE_TITLE": "Пробная подписка на {days} дней",
"PAID_TRIAL_STARS_LABEL": "Пробная подписка",
"PAID_TRIAL_STARS_WAITING": "⭐ Для оплаты пробной подписки нажмите кнопку оплаты в сообщении выше.\n\nПосле успешной оплаты подписка будет активирована автоматически.",
"PAID_TRIAL_YOOKASSA_SBP": "🏦 <b>Оплата через СБП</b>\n\nОтсканируйте QR-код или перейдите по ссылке для оплаты.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_YOOKASSA_CARD": "💳 <b>Оплата картой</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_CRYPTOBOT": "🪙 <b>Оплата через CryptoBot</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_HELEKET": "🪙 <b>Оплата через Heleket</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_MULENPAY": "💳 <b>Оплата через {name}</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_PAL24": "💳 <b>Оплата через PayPalych</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_WATA": "💳 <b>Оплата через WATA</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_PAY_BALANCE": "💳 Оплатить с баланса",
"PAID_TRIAL_BALANCE_SUCCESS": "✅ Пробная подписка успешно оплачена!\n\nАктивация выполняется...",
"PERIOD": "Период",
"TRAFFIC": "Трафик",
"DEVICES": "Устройства",
"PRICE": "Стоимость",
"YOUR_BALANCE": "Ваш баланс",
"UNBLOCK": "✅ Разблокировать",
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
+2
View File
@@ -1125,6 +1125,8 @@
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банківська картка</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему швидких платежів YooKassa",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банківська картка (WATA)</b>",
"PAYMENT_HELEKET_MARKUP_LABEL": "Націнка провайдера",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Знижка провайдера",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На головну",
+2
View File
@@ -1123,6 +1123,8 @@
"PAYMENT_METHOD_YOOKASSA_NAME":"💳<b>银行卡</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION":"通过YooKassa快速支付系统",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME":"🏦<b>SBP(YooKassa)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION":"通过WATA",
"PAYMENT_METHOD_WATA_NAME":"💳<b>银行卡(WATA)</b>",
"PAYMENT_HELEKET_MARKUP_LABEL":"服务商加价",
"PAYMENT_HELEKET_DISCOUNT_LABEL":"服务商折扣",
"PAYMENT_RETURN_HOME_BUTTON":"🏠返回首页",
+4 -7
View File
@@ -44,13 +44,10 @@ class AuthMiddleware(BaseMiddleware):
data: Dict[str, Any]
) -> Any:
# Глобальная проверка: если callback с недоступным сообщением — игнорируем
if isinstance(event, CallbackQuery) and isinstance(event.message, InaccessibleMessage):
try:
await event.answer()
except Exception:
pass
return None
# Callback с недоступным сообщением (>48ч) — пропускаем к хендлерам,
# они сами отправят новое сообщение через edit_or_answer_photo
# if isinstance(event, CallbackQuery) and isinstance(event.message, InaccessibleMessage):
# pass # Раньше здесь был return None, теперь пропускаем дальше
user: TgUser = None
if isinstance(event, (Message, CallbackQuery)):
+78 -7
View File
@@ -7,6 +7,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
from aiogram.enums import ChatMemberStatus
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.config import settings
from app.database.database import AsyncSessionLocal
@@ -23,6 +24,58 @@ from app.services.admin_notification_service import AdminNotificationService
logger = logging.getLogger(__name__)
# Ключ для хранения pending_start_payload в Redis (резервный механизм)
REDIS_PAYLOAD_KEY_PREFIX = "pending_start_payload:"
REDIS_PAYLOAD_TTL = 3600 # 1 час
async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool:
"""Сохраняет pending_start_payload в Redis напрямую (резервный механизм)."""
try:
redis_client = aioredis.from_url(settings.REDIS_URL)
key = f"{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}"
await redis_client.set(key, payload, ex=REDIS_PAYLOAD_TTL)
await redis_client.aclose()
logger.info(
"💾 [Redis fallback] Сохранен payload '%s' для пользователя %s",
payload,
telegram_id,
)
return True
except Exception as e:
logger.error(
"❌ [Redis fallback] Ошибка сохранения payload для %s: %s",
telegram_id,
e,
)
return False
async def get_pending_payload_from_redis(telegram_id: int) -> Optional[str]:
"""Получает pending_start_payload из Redis (резервный механизм)."""
try:
redis_client = aioredis.from_url(settings.REDIS_URL)
key = f"{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}"
payload = await redis_client.get(key)
await redis_client.aclose()
if payload:
return payload.decode("utf-8") if isinstance(payload, bytes) else payload
return None
except Exception as e:
logger.debug("❌ [Redis fallback] Ошибка получения payload для %s: %s", telegram_id, e)
return None
async def delete_pending_payload_from_redis(telegram_id: int) -> None:
"""Удаляет pending_start_payload из Redis."""
try:
redis_client = aioredis.from_url(settings.REDIS_URL)
key = f"{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}"
await redis_client.delete(key)
await redis_client.aclose()
except Exception:
pass
class ChannelCheckerMiddleware(BaseMiddleware):
"""
@@ -170,8 +223,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
event: TelegramObject,
bot: Optional[Bot] = None,
) -> None:
if not state:
return
telegram_id = None
if isinstance(event, Message):
telegram_id = event.from_user.id if event.from_user else None
elif isinstance(event, CallbackQuery):
telegram_id = event.from_user.id if event.from_user else None
message: Optional[Message] = None
if isinstance(event, Message):
@@ -194,11 +250,26 @@ class ChannelCheckerMiddleware(BaseMiddleware):
payload = parts[1]
state_data = await state.get_data() or {}
if state_data.get("pending_start_payload") != payload:
state_data["pending_start_payload"] = payload
await state.set_data(state_data)
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
# Сохраняем в FSM state
if state:
state_data = await state.get_data() or {}
if state_data.get("pending_start_payload") != payload:
state_data["pending_start_payload"] = payload
await state.set_data(state_data)
logger.info(
"💾 Сохранен start payload '%s' для пользователя %s (FSM)",
payload,
telegram_id,
)
else:
logger.warning(
"⚠️ _capture_start_payload: state=None для пользователя %s",
telegram_id,
)
# Также сохраняем в Redis как резерв (на случай потери FSM state)
if telegram_id:
await save_pending_payload_to_redis(telegram_id, payload)
if bot and message.from_user:
await self._try_send_campaign_visit_notification(
+13 -1
View File
@@ -25,11 +25,15 @@ class GlobalErrorMiddleware(BaseMiddleware):
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest):
error_message = str(error).lower()
if self._is_old_query_error(error_message):
return await self._handle_old_query(event, error)
elif self._is_message_not_modified_error(error_message):
return await self._handle_message_not_modified(event, error)
elif self._is_topic_required_error(error_message):
# Канал с топиками — просто игнорируем
logger.debug(f"📋 [GlobalErrorMiddleware] Игнорируем ошибку топика: {error}")
return None
elif self._is_bad_request_error(error_message):
return await self._handle_bad_request(event, error)
else:
@@ -53,6 +57,14 @@ class GlobalErrorMiddleware(BaseMiddleware):
"bot was blocked by the user",
"user is deactivated"
])
def _is_topic_required_error(self, error_message: str) -> bool:
return any(phrase in error_message for phrase in [
"topic must be specified",
"topic_closed",
"topic_deleted",
"forum_closed"
])
async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest):
if isinstance(event, CallbackQuery):
+26 -5
View File
@@ -1,6 +1,6 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from datetime import datetime
from datetime import datetime, timedelta
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
@@ -8,12 +8,18 @@ from app.database.models import SubscriptionStatus
logger = logging.getLogger(__name__)
# Буфер времени перед деактивацией (защита от race condition при продлении)
EXPIRATION_BUFFER_MINUTES = 5
class SubscriptionStatusMiddleware(BaseMiddleware):
"""
Проверяет статус подписки пользователя.
ВАЖНО: Использует db и db_user из data, которые уже загружены в AuthMiddleware.
Не создаёт дополнительных сессий БД.
Деактивирует подписку только если она истекла более чем на EXPIRATION_BUFFER_MINUTES минут.
Это защищает от race conditions при продлении подписки.
"""
async def __call__(
@@ -35,11 +41,26 @@ class SubscriptionStatusMiddleware(BaseMiddleware):
subscription.end_date and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
await db.commit()
# Вычисляем насколько давно истекла подписка
time_since_expiry = current_time - subscription.end_date
logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)")
# Деактивируем только если прошло больше буфера (защита от race condition)
if time_since_expiry > timedelta(minutes=EXPIRATION_BUFFER_MINUTES):
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
await db.commit()
logger.warning(
f"⏰ Middleware DEACTIVATION: подписка {subscription.id} "
f"(user_id={user.id}) деактивирована. "
f"end_date={subscription.end_date}, просрочена на {time_since_expiry}"
)
else:
# Подписка только что истекла - не деактивируем сразу (может быть продление)
logger.debug(
f"⏰ Middleware: подписка пользователя {user.id} истекла недавно "
f"({time_since_expiry}), ждём буфер {EXPIRATION_BUFFER_MINUTES} мин"
)
except Exception as e:
logger.error(f"Ошибка проверки статуса подписки: {e}")
+38 -9
View File
@@ -8,6 +8,7 @@ import hmac
import logging
import time
from typing import Any, Dict, Optional
from urllib.parse import unquote_plus
import httpx
@@ -278,9 +279,15 @@ class CloudPaymentsService:
"""
Verify CloudPayments webhook signature.
CloudPayments uses two different HMAC headers:
- Content-HMAC: calculated from URL-encoded body (raw)
- X-Content-HMAC: calculated from URL-decoded body
This method tries both variants to ensure compatibility.
Args:
body: Raw request body bytes
signature: Signature from X-Content-HMAC header
signature: Signature from X-Content-HMAC or Content-HMAC header
api_secret: CloudPayments API secret
Returns:
@@ -289,15 +296,37 @@ class CloudPaymentsService:
if not signature or not api_secret:
return False
calculated = base64.b64encode(
hmac.new(
api_secret.encode(),
body,
hashlib.sha256,
).digest()
).decode()
def calc_hmac(data: bytes) -> str:
return base64.b64encode(
hmac.new(
api_secret.encode(),
data,
hashlib.sha256,
).digest()
).decode()
return hmac.compare_digest(calculated, signature)
# Try with raw (URL-encoded) body first (for Content-HMAC)
calculated_raw = calc_hmac(body)
if hmac.compare_digest(calculated_raw, signature):
return True
# Try with URL-decoded body (for X-Content-HMAC)
calculated_decoded = None
try:
decoded_body = unquote_plus(body.decode("utf-8")).encode("utf-8")
calculated_decoded = calc_hmac(decoded_body)
if hmac.compare_digest(calculated_decoded, signature):
return True
except Exception:
pass
logger.warning(
"CloudPayments signature mismatch: expected_raw=%s..., expected_decoded=%s..., got=%s...",
calculated_raw[:20],
calculated_decoded[:20] if calculated_decoded else "N/A",
signature[:20],
)
return False
@staticmethod
def parse_webhook_data(form_data: Dict[str, Any]) -> Dict[str, Any]:
+1 -1
View File
@@ -266,7 +266,7 @@ class FreekassaService:
params["i"] = ps_id
query = "&".join(f"{k}={v}" for k, v in params.items())
return f"https://pay.freekassa.ru/?{query}"
return f"https://pay.fk.money/?{query}"
async def create_order(
self,
+293
View File
@@ -0,0 +1,293 @@
"""Сервис для работы с API KassaAI (api.fk.life)."""
import hashlib
import hmac
import time
import logging
import asyncio
from typing import Optional, Dict, Any, Set
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
# Кэш для публичного IP
_cached_public_ip: Optional[str] = None
_ip_fetch_lock = asyncio.Lock()
API_BASE_URL = "https://api.fk.life/v1"
# Сервисы для определения публичного IP
IP_SERVICES = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
"https://ipinfo.io/ip",
]
async def get_public_ip() -> str:
"""
Получает публичный IP сервера.
1. Проверяет переменную окружения SERVER_PUBLIC_IP
2. Если нет - запрашивает через внешние сервисы и кэширует
"""
global _cached_public_ip
env_ip = getattr(settings, 'SERVER_PUBLIC_IP', None)
if env_ip:
return env_ip
if _cached_public_ip:
return _cached_public_ip
async with _ip_fetch_lock:
if _cached_public_ip:
return _cached_public_ip
async with aiohttp.ClientSession() as session:
for service_url in IP_SERVICES:
try:
async with session.get(
service_url,
timeout=aiohttp.ClientTimeout(total=5)
) as response:
if response.status == 200:
ip = (await response.text()).strip()
if ip and len(ip.split('.')) == 4:
_cached_public_ip = ip
logger.info(f"KassaAI: определён публичный IP сервера: {ip}")
return ip
except Exception as e:
logger.debug(f"KassaAI: не удалось получить IP от {service_url}: {e}")
continue
fallback_ip = "127.0.0.1"
logger.warning(f"KassaAI: не удалось определить публичный IP, используем fallback: {fallback_ip}")
_cached_public_ip = fallback_ip
return fallback_ip
class KassaAiService:
"""Сервис для работы с API KassaAI."""
def __init__(self):
self._shop_id: Optional[int] = None
self._api_key: Optional[str] = None
self._secret2: Optional[str] = None
@property
def shop_id(self) -> int:
if self._shop_id is None:
self._shop_id = settings.KASSA_AI_SHOP_ID
return self._shop_id or 0
@property
def api_key(self) -> str:
if self._api_key is None:
self._api_key = settings.KASSA_AI_API_KEY
return self._api_key or ""
@property
def secret2(self) -> str:
if self._secret2 is None:
self._secret2 = settings.KASSA_AI_SECRET_WORD_2
return self._secret2 or ""
def _generate_hmac_signature(self, params: Dict[str, Any]) -> str:
"""
Генерирует подпись для API запроса (HMAC-SHA256).
Сортирует ключи, соединяет значения через |
"""
sign_data = {k: v for k, v in params.items() if k != "signature"}
sorted_keys = sorted(sign_data.keys())
msg = "|".join(str(sign_data[k]) for k in sorted_keys)
return hmac.new(
self.api_key.encode("utf-8"),
msg.encode("utf-8"),
hashlib.sha256
).hexdigest()
def verify_webhook_signature(
self, shop_id: int, amount: float, order_id: str, sign: str
) -> bool:
"""
Проверяет подпись webhook уведомления.
Формат: MD5(shop_id:amount:secret2:order_id)
"""
try:
# Приводим amount к строке без лишних нулей
if isinstance(amount, float) and amount.is_integer():
amount_str = str(int(amount))
else:
amount_str = str(amount)
sign_str = f"{shop_id}:{amount_str}:{self.secret2}:{order_id}"
expected_sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest()
return expected_sign.lower() == sign.lower()
except Exception as e:
logger.error(f"KassaAI webhook verify error: {e}")
return False
async def create_order(
self,
order_id: str,
amount: float,
currency: str = "RUB",
email: Optional[str] = None,
ip: Optional[str] = None,
payment_system_id: Optional[int] = None,
) -> Dict[str, Any]:
"""
Создает заказ через API KassaAI.
POST /orders/create
payment_system_id:
- 44 = СБП (QR код)
- 36 = Банковские карты РФ
- 43 = SberPay
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
# Payment system из настроек или default (44 = СБП)
ps_id = payment_system_id or settings.KASSA_AI_PAYMENT_SYSTEM_ID or 44
# Email: используем telegram-формат если не указан
target_email = email or f"user_{order_id}@telegram.org"
# Определяем публичный IP сервера
server_ip = ip or await get_public_ip()
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
"paymentId": str(order_id),
"i": ps_id,
"email": target_email,
"ip": server_ip,
"amount": final_amount,
"currency": currency,
}
# Генерируем подпись HMAC-SHA256
params["signature"] = self._generate_hmac_signature(params)
logger.info(f"KassaAI API create_order: shop_id={self.shop_id}, order_id={order_id}, amount={final_amount}, ps_id={ps_id}")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/orders/create",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.info(f"KassaAI API response: {text}")
data = await response.json()
# Проверяем на ошибку
if data.get("type") == "error":
error_msg = data.get("error") or data.get("message") or "Unknown error"
logger.error(f"KassaAI create_order error: {error_msg}")
raise Exception(f"KassaAI API error: {error_msg}")
if data.get("type") == "success":
return {
"location": data.get("location"),
"orderId": data.get("orderId"),
"paymentId": data.get("paymentId"),
}
# Неизвестный формат ответа
logger.error(f"KassaAI unexpected response: {data}")
raise Exception(f"KassaAI unexpected response format")
except aiohttp.ClientError as e:
logger.exception(f"KassaAI API connection error: {e}")
raise
async def create_order_and_get_url(
self,
order_id: str,
amount: float,
currency: str = "RUB",
email: Optional[str] = None,
ip: Optional[str] = None,
payment_system_id: Optional[int] = None,
) -> str:
"""
Создает заказ через API и возвращает URL для оплаты.
"""
result = await self.create_order(
order_id=order_id,
amount=amount,
currency=currency,
email=email,
ip=ip,
payment_system_id=payment_system_id,
)
location = result.get("location")
if not location:
raise Exception("KassaAI API did not return payment URL (location)")
return location
async def get_order_status(self, order_id: str) -> Dict[str, Any]:
"""
Получает статус заказа.
POST /orders
"""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
"paymentId": str(order_id),
}
params["signature"] = self._generate_hmac_signature(params)
logger.debug(f"KassaAI get_order_status: order_id={order_id}")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/orders",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.debug(f"KassaAI get_order_status response: {text}")
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"KassaAI API connection error: {e}")
raise
async def get_balance(self) -> Dict[str, Any]:
"""Получает баланс магазина."""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
}
params["signature"] = self._generate_hmac_signature(params)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/balance",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"KassaAI API connection error: {e}")
raise
# Singleton instance
kassa_ai_service = KassaAiService()
+13 -6
View File
@@ -284,12 +284,18 @@ class MonitoringService:
if not user or not user.remnawave_uuid:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
# Обновляем subscription в сессии, чтобы избежать detached instance
try:
await db.refresh(subscription)
except Exception:
pass
current_time = datetime.utcnow()
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
await db.commit()
@@ -581,8 +587,9 @@ class MonitoringService:
)
continue
except TelegramBadRequest as error:
logger.error(
"❌ Ошибка Telegram при проверке подписки пользователя %s: %s",
# PARTICIPANT_ID_INVALID - пользователь никогда не был в канале, это нормально
logger.warning(
"⚠️ Ошибка Telegram при проверке подписки пользователя %s: %s",
user.telegram_id,
error,
)
+2
View File
@@ -16,6 +16,7 @@ from .platega import PlategaPaymentMixin
from .wata import WataPaymentMixin
from .cloudpayments import CloudPaymentsPaymentMixin
from .freekassa import FreekassaPaymentMixin
from .kassa_ai import KassaAiPaymentMixin
__all__ = [
"PaymentCommonMixin",
@@ -30,4 +31,5 @@ __all__ = [
"WataPaymentMixin",
"CloudPaymentsPaymentMixin",
"FreekassaPaymentMixin",
"KassaAiPaymentMixin",
]
+3 -1
View File
@@ -260,7 +260,9 @@ class CloudPaymentsPaymentMixin:
# Auto-purchase if enabled
auto_purchase_success = False
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user)
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db, user, bot=getattr(self, "bot", None)
)
except Exception as error:
logger.exception("Ошибка автопокупки после CloudPayments: %s", error)
+490
View File
@@ -0,0 +1,490 @@
"""Mixin для интеграции с KassaAI (api.fk.life)."""
from __future__ import annotations
import json
import uuid
import logging
from datetime import datetime, timedelta
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.kassa_ai_service import kassa_ai_service
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
from app.utils.payment_logger import payment_logger as logger
class KassaAiPaymentMixin:
"""Mixin для работы с платежами KassaAI."""
async def create_kassa_ai_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса",
email: Optional[str] = None,
language: str = "ru",
) -> Optional[Dict[str, Any]]:
"""
Создает платеж KassaAI.
Args:
db: Сессия БД
user_id: ID пользователя
amount_kopeks: Сумма в копейках
description: Описание платежа
email: Email пользователя
language: Язык интерфейса
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_kassa_ai_enabled():
logger.error("KassaAI не настроен")
return None
# Валидация лимитов
if amount_kopeks < settings.KASSA_AI_MIN_AMOUNT_KOPEKS:
logger.warning(
"KassaAI: сумма %s меньше минимальной %s",
amount_kopeks,
settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.KASSA_AI_MAX_AMOUNT_KOPEKS:
logger.warning(
"KassaAI: сумма %s больше максимальной %s",
amount_kopeks,
settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
)
return None
# Генерируем уникальный order_id
order_id = f"kai_{user_id}_{uuid.uuid4().hex[:12]}"
amount_rubles = amount_kopeks / 100
currency = settings.KASSA_AI_CURRENCY
# Срок действия платежа (1 час по умолчанию)
expires_at = datetime.utcnow() + timedelta(hours=1)
# Метаданные
metadata = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"description": description,
"language": language,
"type": "balance_topup",
}
try:
# Используем API для создания заказа
result = await kassa_ai_service.create_order(
order_id=order_id,
amount=amount_rubles,
currency=currency,
email=email,
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
)
payment_url = result.get("location")
if not payment_url:
logger.error("KassaAI API не вернул URL платежа")
return None
logger.info(
"KassaAI API: создан заказ order_id=%s, url=%s",
order_id,
payment_url,
)
# Импортируем CRUD модуль
kassa_ai_crud = import_module("app.database.crud.kassa_ai")
# Сохраняем в БД
local_payment = await kassa_ai_crud.create_kassa_ai_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
expires_at=expires_at,
metadata_json=json.dumps(metadata, ensure_ascii=False),
)
logger.info(
"KassaAI: создан платеж order_id=%s, user_id=%s, amount=%s %s",
order_id,
user_id,
amount_rubles,
currency,
)
return {
"order_id": order_id,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"currency": currency,
"payment_url": payment_url,
"expires_at": expires_at.isoformat(),
"local_payment_id": local_payment.id,
}
except Exception as e:
logger.exception("KassaAI: ошибка создания платежа: %s", e)
return None
async def process_kassa_ai_webhook(
self,
db: AsyncSession,
*,
merchant_id: int,
amount: float,
order_id: str,
sign: str,
intid: str,
cur_id: Optional[int] = None,
) -> bool:
"""
Обрабатывает webhook от KassaAI.
Args:
db: Сессия БД
merchant_id: ID магазина (MERCHANT_ID)
amount: Сумма платежа (AMOUNT)
order_id: Номер заказа (MERCHANT_ORDER_ID)
sign: Подпись (SIGN)
intid: ID транзакции KassaAI
cur_id: ID валюты/платежной системы (CUR_ID)
Returns:
True если платеж успешно обработан
"""
try:
# Проверка подписи
if not kassa_ai_service.verify_webhook_signature(
merchant_id, amount, order_id, sign
):
logger.warning(
"KassaAI webhook: неверная подпись для order_id=%s", order_id
)
return False
# Импортируем CRUD модуль
kassa_ai_crud = import_module("app.database.crud.kassa_ai")
# Получаем платеж из БД
payment = await kassa_ai_crud.get_kassa_ai_payment_by_order_id(
db, order_id
)
if not payment:
logger.warning(
"KassaAI webhook: платеж не найден order_id=%s", order_id
)
return False
# Проверка дублирования
if payment.is_paid:
logger.info(
"KassaAI webhook: платеж уже обработан order_id=%s", order_id
)
return True
# Проверка суммы
expected_amount = payment.amount_kopeks / 100
if abs(amount - expected_amount) > 0.01:
logger.warning(
"KassaAI webhook: несоответствие суммы ожидалось=%s, получено=%s",
expected_amount,
amount,
)
return False
# Обновляем статус платежа
callback_payload = {
"merchant_id": merchant_id,
"amount": amount,
"order_id": order_id,
"intid": intid,
"cur_id": cur_id,
}
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status="success",
is_paid=True,
kassa_ai_order_id=intid,
payment_system_id=cur_id,
callback_payload=callback_payload,
)
# Финализируем платеж (начисляем баланс, создаем транзакцию)
return await self._finalize_kassa_ai_payment(
db, payment, intid=intid, trigger="webhook"
)
except Exception as e:
logger.exception("KassaAI webhook: ошибка обработки: %s", e)
return False
async def _finalize_kassa_ai_payment(
self,
db: AsyncSession,
payment: Any,
*,
intid: Optional[str],
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
payment_module = import_module("app.services.payment_service")
if payment.transaction_id:
logger.info(
"KassaAI платеж %s уже привязан к транзакции (trigger=%s)",
payment.order_id,
trigger,
)
return True
# Получаем пользователя
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден для KassaAI платежа %s (trigger=%s)",
payment.user_id,
payment.order_id,
trigger,
)
return False
# Создаем транзакцию
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через KassaAI (#{intid or payment.order_id})",
payment_method=PaymentMethod.KASSA_AI,
external_id=str(intid) if intid else payment.order_id,
is_completed=True,
)
# Связываем платеж с транзакцией
kassa_ai_crud = import_module("app.database.crud.kassa_ai")
await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status=payment.status,
transaction_id=transaction.id,
)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Начисляем баланс
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "Первое пополнение" if was_first_topup else "Пополнение"
await db.commit()
# Обработка реферального пополнения
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения KassaAI: %s", error
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
await db.refresh(payment)
# Отправка уведомления админам
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки админ уведомления KassaAI: %s", error
)
# Отправка уведомления пользователю
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
display_name = settings.get_kassa_ai_display_name()
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"💳 Способ: {display_name}\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю KassaAI: %s", error
)
# Автопокупка подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
activation_notification_sent = False
if not auto_purchase_success:
try:
_, activation_notification_sent = await auto_activate_subscription_after_topup(
db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks
)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
# Отправляем уведомление только если его ещё не отправили
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER",
"У вас есть незавершенное оформление подписки. Вернуться?",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"BALANCE_TOPUP_CART_BUTTON",
"🛒 Продолжить оформление",
),
callback_data="return_to_saved_cart",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
"✅ Баланс пополнен на "
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
f"{cart_message}"
),
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
logger.info(
"✅ Обработан KassaAI платеж %s для пользователя %s (trigger=%s)",
payment.order_id,
payment.user_id,
trigger,
)
return True
async def check_kassa_ai_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> Optional[Dict[str, Any]]:
"""
Проверяет статус платежа через API.
Args:
db: Сессия БД
order_id: Номер заказа
Returns:
Данные о статусе платежа
"""
try:
status_data = await kassa_ai_service.get_order_status(order_id)
return status_data
except Exception as e:
logger.exception("KassaAI: ошибка проверки статуса: %s", e)
return None
+6 -3
View File
@@ -122,11 +122,13 @@ class YooKassaPaymentMixin:
except Exception as e:
logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}")
# Preserve existing type from metadata if passed (e.g., "trial")
existing_type = payment_metadata.get("type")
payment_metadata.update(
{
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup",
"type": existing_type or "balance_topup",
}
)
@@ -225,11 +227,13 @@ class YooKassaPaymentMixin:
except Exception as e:
logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}")
# Preserve existing type from metadata if passed (e.g., "trial")
existing_type = payment_metadata.get("type")
payment_metadata.update(
{
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup_sbp",
"type": existing_type or "balance_topup_sbp",
}
)
@@ -664,7 +668,6 @@ class YooKassaPaymentMixin:
# Уведомление пользователю
if getattr(self, "bot", None):
try:
from app.config import settings
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
+7
View File
@@ -30,6 +30,7 @@ from app.services.payment import (
)
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.yookassa_service import YooKassaService
from app.services.wata_service import WataService
from app.services.cloudpayments_service import CloudPaymentsService
@@ -171,6 +172,11 @@ async def get_wata_payment_by_id(*args, **kwargs):
return await wata_crud.get_wata_payment_by_id(*args, **kwargs)
# Алиас для совместимости с хендлерами
async def get_wata_payment_by_local_id(*args, **kwargs):
return await get_wata_payment_by_id(*args, **kwargs)
async def get_wata_payment_by_order_id(*args, **kwargs):
wata_crud = import_module("app.database.crud.wata")
return await wata_crud.get_wata_payment_by_order_id(*args, **kwargs)
@@ -299,6 +305,7 @@ class PaymentService(
WataPaymentMixin,
CloudPaymentsPaymentMixin,
FreekassaPaymentMixin,
KassaAiPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
+80 -16
View File
@@ -1430,13 +1430,30 @@ class RemnaWaveService:
logger.warning(f"⚠️ Не удалось удалить серверы подписки: {servers_error}")
from app.database.models import SubscriptionStatus
# Проверяем, была ли это платная подписка
was_paid = (
not subscription.is_trial
or getattr(db_user, 'has_had_paid_subscription', False)
)
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
if was_paid:
# Для платных подписок - НЕ сбрасываем is_trial и end_date!
# Сохраняем оригинальные значения чтобы можно было восстановить
logger.warning(
f"⚠️ ПЛАТНАЯ подписка пользователя {telegram_id} отключена (нет в панели), "
f"но is_trial={subscription.is_trial} и end_date={subscription.end_date} СОХРАНЕНЫ"
)
else:
# Для триальных подписок - сбрасываем как раньше
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
@@ -1620,21 +1637,43 @@ class RemnaWaveService:
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}")
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
# Это защищает от ситуации когда подписка была продлена в боте,
# но RemnaWave ещё не получил обновление или вернул старую дату
if abs((subscription.end_date - expire_at).total_seconds()) > 60:
if expire_at > subscription.end_date:
# RemnaWave имеет более позднюю дату - обновляем
subscription.end_date = expire_at
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
else:
# Локальная дата позже - НЕ перезаписываем, логируем предупреждение
logger.warning(
f"⚠️ Sync: пропускаем обновление end_date для user {getattr(user, 'telegram_id', '?')}: "
f"локальная дата ({subscription.end_date}) позже чем в RemnaWave ({expire_at})"
)
current_time = self._now_utc()
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
new_status = SubscriptionStatus.ACTIVE.value
elif subscription.end_date <= current_time:
new_status = SubscriptionStatus.EXPIRED.value
elif panel_status == 'DISABLED':
new_status = SubscriptionStatus.DISABLED.value
elif subscription.end_date <= current_time:
# КРИТИЧНО: НЕ деактивируем если текущий статус ACTIVE
# Это защищает от race condition когда sync использует старую end_date из памяти,
# а реальная end_date уже обновлена продлением
if subscription.status == SubscriptionStatus.ACTIVE.value:
logger.warning(
f"⚠️ Sync: пропускаем деактивацию подписки user {getattr(user, 'telegram_id', '?')}: "
f"статус ACTIVE, end_date в памяти ({subscription.end_date}) <= now. "
f"Деактивация будет выполнена через middleware с буфером."
)
new_status = subscription.status # Сохраняем текущий статус
else:
new_status = SubscriptionStatus.EXPIRED.value
else:
new_status = subscription.status
new_status = subscription.status
if subscription.status != new_status:
subscription.status = new_status
logger.debug(f"Обновлен статус подписки: {new_status}")
@@ -2143,7 +2182,25 @@ class RemnaWaveService:
return False
async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool:
"""
ОПАСНАЯ ФУНКЦИЯ: Полностью сбрасывает все данные пользователя включая баланс!
Используйте только для полной очистки пользователя.
"""
try:
# Предупреждение для платных пользователей
was_paid = (
user.has_had_paid_subscription
or (user.subscription and not user.subscription.is_trial)
or user.balance_kopeks > 0
)
if was_paid:
logger.warning(
f"⚠️ ВНИМАНИЕ: force_cleanup_user_data вызвана для ПЛАТНОГО пользователя {user.telegram_id}! "
f"has_had_paid_subscription={user.has_had_paid_subscription}, "
f"balance={user.balance_kopeks}, "
f"is_trial={user.subscription.is_trial if user.subscription else 'N/A'}"
)
logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}")
if user.remnawave_uuid:
@@ -2384,8 +2441,15 @@ class RemnaWaveService:
issues_fixed = 0
current_time = self._now_utc()
if subscription.end_date <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value:
logger.info(f"🔧 Исправляем статус просроченной подписки {user.telegram_id}")
# Добавляем буфер 5 минут для защиты от race condition при продлении
expiry_buffer = timedelta(minutes=5)
if (subscription.end_date + expiry_buffer <= current_time and
subscription.status == SubscriptionStatus.ACTIVE.value):
time_since_expiry = current_time - subscription.end_date
logger.warning(
f"🔧 fix_data_issues: деактивируем подписку {subscription.id} "
f"(user={user.telegram_id}), просрочена на {time_since_expiry}"
)
subscription.status = SubscriptionStatus.EXPIRED.value
issues_fixed += 1
+7 -5
View File
@@ -16,6 +16,7 @@ from sqlalchemy.sql import false, true
from app.config import settings
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import REAL_PAYMENT_METHODS
from app.database.database import AsyncSessionLocal
from app.database.models import (
PaymentMethod,
@@ -491,6 +492,10 @@ class ReportingService:
)
def _deposit_query_excluding_referrals(self, start_utc: datetime, end_utc: datetime):
"""Запрос депозитов только по реальным платежам.
Исключаются: колесо удачи, промокоды, админские пополнения, оплата с баланса.
"""
return select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
@@ -500,11 +505,8 @@ class ReportingService:
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
self._exclude_referral_deposits_condition(),
# Исключаем ручные (админские) пополнения из статистики
or_(
Transaction.payment_method.is_(None),
Transaction.payment_method != PaymentMethod.MANUAL.value,
),
# Только реальные платежи (исключаем колесо, промокоды, админские, баланс)
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
async def _get_top_referrers(
@@ -56,6 +56,8 @@ class AutoExtendContext:
traffic_limit_gb: Optional[int] = None
squad_uuid: Optional[str] = None
consume_promo_offer: bool = False
tariff_id: Optional[int] = None
allowed_squads: Optional[list] = None
async def _prepare_auto_purchase(
@@ -273,6 +275,7 @@ async def _prepare_auto_extend_context(
squad_uuid = cart_data.get("squad_uuid")
consume_promo_offer = bool(cart_data.get("consume_promo_offer"))
allowed_squads = cart_data.get("allowed_squads")
return AutoExtendContext(
subscription=subscription,
@@ -283,16 +286,26 @@ async def _prepare_auto_extend_context(
traffic_limit_gb=traffic_limit_gb,
squad_uuid=squad_uuid,
consume_promo_offer=consume_promo_offer,
tariff_id=tariff_id,
allowed_squads=allowed_squads,
)
def _apply_extension_updates(context: AutoExtendContext) -> None:
"""
Применяет обновления лимитов подписки (трафик, устройства, серверы).
Применяет обновления лимитов подписки (трафик, устройства, серверы, тариф).
НЕ изменяет is_trial - это делается позже после успешного коммита продления.
"""
subscription = context.subscription
# Обновляем tariff_id если указан в контексте
if context.tariff_id is not None:
subscription.tariff_id = context.tariff_id
# Обновляем allowed_squads если указаны (заменяем полностью)
if context.allowed_squads is not None:
subscription.connected_squads = context.allowed_squads
# Обновляем лимиты для триальной подписки
if subscription.is_trial:
# НЕ удаляем триал здесь! Это будет сделано после успешного extend_subscription()
@@ -373,14 +386,25 @@ async def _auto_extend_subscription(
subscription = prepared.subscription
old_end_date = subscription.end_date
was_trial = subscription.is_trial # Запоминаем, была ли подписка триальной
old_tariff_id = subscription.tariff_id # Запоминаем старый тариф для определения смены
_apply_extension_updates(prepared)
# Определяем, произошла ли смена тарифа
is_tariff_change = (
prepared.tariff_id is not None
and old_tariff_id != prepared.tariff_id
)
try:
# При смене тарифа передаём traffic_limit_gb для сброса трафика в БД
updated_subscription = await extend_subscription(
db,
subscription,
prepared.period_days,
tariff_id=prepared.tariff_id if is_tariff_change else None,
traffic_limit_gb=prepared.traffic_limit_gb if is_tariff_change else None,
device_limit=prepared.device_limit if is_tariff_change else None,
)
# НОВОЕ: Конвертируем триал в платную подписку ТОЛЬКО после успешного продления
@@ -424,12 +448,14 @@ async def _auto_extend_subscription(
)
subscription_service = SubscriptionService()
# При смене тарифа ВСЕГДА сбрасываем трафик, иначе по настройке
should_reset_traffic = is_tariff_change or settings.RESET_TRAFFIC_ON_PAYMENT
try:
await subscription_service.update_remnawave_user(
db,
updated_subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="продление подписки",
reset_traffic=should_reset_traffic,
reset_reason="смена тарифа" if is_tariff_change else "продление подписки",
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
@@ -528,6 +554,466 @@ async def _auto_extend_subscription(
return True
async def _auto_purchase_tariff(
db: AsyncSession,
user: User,
cart_data: dict,
*,
bot: Optional[Bot] = None,
) -> bool:
"""Автоматическая покупка периодного тарифа из сохранённой корзины."""
from datetime import datetime
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.crud.server_squad import get_all_server_squads
from app.database.models import TransactionType
tariff_id = _safe_int(cart_data.get("tariff_id"))
period_days = _safe_int(cart_data.get("period_days"))
discount_percent = _safe_int(cart_data.get("discount_percent"))
if not tariff_id or period_days <= 0:
logger.warning(
"🔁 Автопокупка тарифа: некорректные данные корзины для пользователя %s (tariff_id=%s, period=%s)",
user.telegram_id,
tariff_id,
period_days,
)
return False
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
logger.warning(
"🔁 Автопокупка тарифа: тариф %s недоступен для пользователя %s",
tariff_id,
user.telegram_id,
)
return False
# Получаем актуальную цену тарифа
prices = tariff.period_prices or {}
base_price = prices.get(str(period_days))
if base_price is None:
logger.warning(
"🔁 Автопокупка тарифа: период %s дней недоступен для тарифа %s",
period_days,
tariff_id,
)
return False
final_price = _apply_promo_discount_for_tariff(base_price, discount_percent)
if user.balance_kopeks < final_price:
logger.info(
"🔁 Автопокупка тарифа: у пользователя %s недостаточно средств (%s < %s)",
user.telegram_id,
user.balance_kopeks,
final_price,
)
return False
# Списываем баланс
try:
description = f"Покупка тарифа {tariff.name} на {period_days} дней"
success = await subtract_user_balance(db, user, final_price, description)
if not success:
logger.warning(
"❌ Автопокупка тарифа: не удалось списать баланс пользователя %s",
user.telegram_id,
)
return False
except Exception as error:
logger.error(
"❌ Автопокупка тарифа: ошибка списания баланса пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
return False
# Получаем список серверов из тарифа
squads = tariff.allowed_squads or []
if not squads:
all_servers, _ = await get_all_server_squads(db, available_only=True)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
# Проверяем есть ли уже подписка
existing_subscription = await get_subscription_by_user_id(db, user.id)
try:
if existing_subscription:
# Продлеваем существующую подписку
subscription = await extend_subscription(
db,
existing_subscription,
days=period_days,
tariff_id=tariff.id,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
)
was_trial_conversion = existing_subscription.is_trial
if was_trial_conversion:
subscription.is_trial = False
subscription.status = "active"
user.has_had_paid_subscription = True
await db.commit()
else:
# Создаём новую подписку
subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=period_days,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
tariff_id=tariff.id,
)
was_trial_conversion = False
except Exception as error:
logger.error(
"❌ Автопокупка тарифа: ошибка создания подписки для пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
await db.rollback()
return False
# Создаём транзакцию
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_price,
description=description,
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка тарифа: не удалось создать транзакцию для пользователя %s: %s",
user.telegram_id,
error,
)
transaction = None
# Обновляем Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="покупка тарифа",
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка тарифа: не удалось обновить Remnawave для пользователя %s: %s",
user.telegram_id,
error,
)
# Очищаем корзину
await user_cart_service.delete_user_cart(user.id)
await clear_subscription_checkout_draft(user.id)
# Уведомления
if bot:
texts = get_texts(getattr(user, "language", "ru"))
period_label = format_period_description(period_days, getattr(user, "language", "ru"))
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db, user, subscription, transaction, period_days, was_trial_conversion
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка тарифа: не удалось уведомить админов о покупке пользователя %s: %s",
user.telegram_id,
error,
)
try:
message = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
"✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
).format(period=period_label)
hint = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
"Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)],
[InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Главное меню"),
callback_data="back_to_menu",
)],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=f"{message}\n\n{hint}",
reply_markup=keyboard,
parse_mode="HTML",
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка тарифа: не удалось уведомить пользователя %s: %s",
user.telegram_id,
error,
)
logger.info(
"✅ Автопокупка тарифа: подписка на тариф %s (%s дней) оформлена для пользователя %s",
tariff.name,
period_days,
user.telegram_id,
)
return True
async def _auto_purchase_daily_tariff(
db: AsyncSession,
user: User,
cart_data: dict,
*,
bot: Optional[Bot] = None,
) -> bool:
"""Автоматическая покупка суточного тарифа из сохранённой корзины."""
from datetime import datetime, timedelta
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.crud.server_squad import get_all_server_squads
from app.database.models import TransactionType
tariff_id = _safe_int(cart_data.get("tariff_id"))
if not tariff_id:
logger.warning(
"🔁 Автопокупка суточного тарифа: нет tariff_id в корзине пользователя %s",
user.telegram_id,
)
return False
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
logger.warning(
"🔁 Автопокупка суточного тарифа: тариф %s недоступен для пользователя %s",
tariff_id,
user.telegram_id,
)
return False
if not getattr(tariff, 'is_daily', False):
logger.warning(
"🔁 Автопокупка суточного тарифа: тариф %s не является суточным для пользователя %s",
tariff_id,
user.telegram_id,
)
return False
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price <= 0:
logger.warning(
"🔁 Автопокупка суточного тарифа: некорректная цена тарифа %s для пользователя %s",
tariff_id,
user.telegram_id,
)
return False
if user.balance_kopeks < daily_price:
logger.info(
"🔁 Автопокупка суточного тарифа: у пользователя %s недостаточно средств (%s < %s)",
user.telegram_id,
user.balance_kopeks,
daily_price,
)
return False
# Списываем баланс за первый день
try:
description = f"Активация суточного тарифа {tariff.name}"
success = await subtract_user_balance(db, user, daily_price, description)
if not success:
logger.warning(
"❌ Автопокупка суточного тарифа: не удалось списать баланс пользователя %s",
user.telegram_id,
)
return False
except Exception as error:
logger.error(
"❌ Автопокупка суточного тарифа: ошибка списания баланса пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
return False
# Получаем список серверов из тарифа
squads = tariff.allowed_squads or []
if not squads:
all_servers, _ = await get_all_server_squads(db, available_only=True)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
# Проверяем есть ли уже подписка
existing_subscription = await get_subscription_by_user_id(db, user.id)
try:
if existing_subscription:
# Обновляем существующую подписку на суточный тариф
# Суточность определяется через tariff.is_daily, поэтому достаточно установить tariff_id
was_trial_conversion = existing_subscription.is_trial # Сохраняем до изменения
existing_subscription.tariff_id = tariff.id
existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb
existing_subscription.device_limit = tariff.device_limit
existing_subscription.connected_squads = squads
existing_subscription.status = "active"
existing_subscription.is_trial = False
existing_subscription.last_daily_charge_at = datetime.utcnow()
existing_subscription.is_daily_paused = False
existing_subscription.end_date = datetime.utcnow() + timedelta(days=1)
if was_trial_conversion:
user.has_had_paid_subscription = True
await db.commit()
await db.refresh(existing_subscription)
subscription = existing_subscription
else:
# Создаём новую суточную подписку
# Суточность определяется через tariff.is_daily
subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=1,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
tariff_id=tariff.id,
)
# Устанавливаем параметры для суточного списания
subscription.last_daily_charge_at = datetime.utcnow()
subscription.is_daily_paused = False
await db.commit()
was_trial_conversion = False
except Exception as error:
logger.error(
"❌ Автопокупка суточного тарифа: ошибка создания подписки для пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
await db.rollback()
return False
# Создаём транзакцию
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=description,
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка суточного тарифа: не удалось создать транзакцию для пользователя %s: %s",
user.telegram_id,
error,
)
transaction = None
# Обновляем Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="активация суточного тарифа",
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка суточного тарифа: не удалось обновить Remnawave для пользователя %s: %s",
user.telegram_id,
error,
)
# Очищаем корзину
await user_cart_service.delete_user_cart(user.id)
await clear_subscription_checkout_draft(user.id)
# Уведомления
if bot:
texts = get_texts(getattr(user, "language", "ru"))
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db, user, subscription, transaction, 1, was_trial_conversion
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка суточного тарифа: не удалось уведомить админов о покупке пользователя %s: %s",
user.telegram_id,
error,
)
try:
message = (
f"✅ <b>Суточный тариф «{tariff.name}» активирован!</b>\n\n"
f"💰 Списано: {daily_price / 100:.0f} ₽ за первый день\n"
f"🔄 Средства будут списываться автоматически раз в сутки.\n\n"
f"ℹ️ Вы можете приостановить подписку в любой момент."
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)],
[InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Главное меню"),
callback_data="back_to_menu",
)],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
reply_markup=keyboard,
parse_mode="HTML",
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка суточного тарифа: не удалось уведомить пользователя %s: %s",
user.telegram_id,
error,
)
logger.info(
"✅ Автопокупка суточного тарифа: тариф %s активирован для пользователя %s",
tariff.name,
user.telegram_id,
)
return True
async def auto_purchase_saved_cart_after_topup(
db: AsyncSession,
user: User,
@@ -551,9 +1037,19 @@ async def auto_purchase_saved_cart_after_topup(
)
cart_mode = cart_data.get("cart_mode") or cart_data.get("mode")
# Обработка продления подписки
if cart_mode == "extend":
return await _auto_extend_subscription(db, user, cart_data, bot=bot)
# Обработка покупки периодного тарифа
if cart_mode == "tariff_purchase":
return await _auto_purchase_tariff(db, user, cart_data, bot=bot)
# Обработка покупки суточного тарифа
if cart_mode == "daily_tariff_purchase":
return await _auto_purchase_daily_tariff(db, user, cart_data, bot=bot)
try:
prepared = await _prepare_auto_purchase(db, user, cart_data)
except PurchaseValidationError as error:
+11 -10
View File
@@ -620,7 +620,7 @@ class MiniAppSubscriptionPurchaseService:
maximum = max(default_devices, settings.DEFAULT_DEVICE_LIMIT) + 10
return PurchaseDevicesConfig(
minimum=1,
minimum=settings.DEFAULT_DEVICE_LIMIT,
maximum=maximum,
default=default_devices,
current=default_devices,
@@ -653,13 +653,13 @@ class MiniAppSubscriptionPurchaseService:
period = context.period_map[period_id]
traffic_value = (
selection_payload.get("traffic_value")
or selection_payload.get("trafficValue")
or selection_payload.get("traffic")
or selection_payload.get("traffic_gb")
or selection_payload.get("trafficGb")
)
# Don't use `or` chaining - 0 is valid for unlimited traffic
traffic_value = None
for key in ("traffic_value", "trafficValue", "traffic", "traffic_gb", "trafficGb"):
value = selection_payload.get(key)
if value is not None:
traffic_value = value
break
if period.traffic.selectable:
available_values = {option.value for option in period.traffic.options}
@@ -1156,19 +1156,20 @@ class MiniAppSubscriptionPurchaseService:
logger.error("Failed to register subscription servers: %s", error)
subscription_service = SubscriptionService()
# При покупке подписки ВСЕГДА сбрасываем трафик в панели
try:
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="miniapp purchase",
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="miniapp purchase",
)
except Exception as remnawave_error: # pragma: no cover - defensive logging
+43 -1
View File
@@ -12,7 +12,7 @@ from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_server_ids_by_uuids
from app.database.crud.server_squad import get_server_ids_by_uuids, get_server_squads_by_uuids
from app.database.crud.subscription import (
add_subscription_servers,
calculate_subscription_total_cost,
@@ -319,6 +319,9 @@ class SubscriptionRenewalService:
if connected_uuids:
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
# Валидация: проверяем доступность серверов для промогруппы пользователя
await self._validate_servers_for_user_promo_group(db, user, connected_uuids)
# В режиме fixed_with_topup при продлении используем фиксированный лимит
if settings.is_traffic_fixed():
traffic_limit = settings.get_fixed_traffic_limit()
@@ -530,6 +533,45 @@ class SubscriptionRenewalService:
old_end_date=old_end_date,
)
async def _validate_servers_for_user_promo_group(
self,
db: AsyncSession,
user: User,
server_uuids: List[str],
) -> None:
"""
Проверяет, что все серверы подписки доступны для промогруппы пользователя.
Логирует предупреждения если серверы недоступны.
"""
if not server_uuids:
return
try:
await db.refresh(user, ["user_promo_groups", "promo_group"])
except Exception:
pass
user_promo_group = user.get_primary_promo_group() if user else None
if not user_promo_group:
return
servers = await get_server_squads_by_uuids(db, server_uuids)
unavailable_servers = []
for server in servers:
if server.allowed_promo_groups:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
if user_promo_group.id not in allowed_ids:
unavailable_servers.append(server.display_name or server.squad_uuid)
if unavailable_servers:
logger.warning(
f"⚠️ Пользователь {user.telegram_id} (promo_group={user_promo_group.name}) "
f"продлевает подписку с серверами, недоступными для его промогруппы: "
f"{', '.join(unavailable_servers)}. "
f"Это может привести к неправильному расчёту цены!"
)
def build_option_payload(
self,
pricing: SubscriptionRenewalPricing,
+17 -9
View File
@@ -320,19 +320,27 @@ class SubscriptionService:
if not user or not user.remnawave_uuid:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
# Загружаем tariff заранее, чтобы избежать lazy loading в async контексте
try:
await db.refresh(subscription, ["tariff"])
except Exception:
pass # tariff может быть None или уже загружен
current_time = datetime.utcnow()
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
# Определяем актуальный статус для отправки в RemnaWave
# НЕ меняем статус подписки здесь - это задача scheduled job
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
# Логируем если статус и end_date не согласованы (для отладки)
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'")
logger.warning(
f"⚠️ update_remnawave_user: подписка {subscription.id} имеет статус ACTIVE, "
f"но end_date ({subscription.end_date}) <= now ({current_time}). "
f"Отправляем в RemnaWave как EXPIRED, но НЕ меняем статус в БД."
)
user_tag = self._resolve_user_tag(subscription)
+29
View File
@@ -219,3 +219,32 @@ class SupportSettingsService:
return cls._save()
return True
# Cabinet notifications (веб-кабинет)
@classmethod
def get_cabinet_user_notifications_enabled(cls) -> bool:
"""Уведомления юзерам в кабинет о ответе админа на тикет."""
cls._load()
if "cabinet_user_notifications_enabled" in cls._data:
return bool(cls._data["cabinet_user_notifications_enabled"])
return True # По умолчанию включено
@classmethod
def set_cabinet_user_notifications_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["cabinet_user_notifications_enabled"] = bool(enabled)
return cls._save()
@classmethod
def get_cabinet_admin_notifications_enabled(cls) -> bool:
"""Уведомления админам в кабинет о новых тикетах."""
cls._load()
if "cabinet_admin_notifications_enabled" in cls._data:
return bool(cls._data["cabinet_admin_notifications_enabled"])
return True # По умолчанию включено
@classmethod
def set_cabinet_admin_notifications_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["cabinet_admin_notifications_enabled"] = bool(enabled)
return cls._save()
+66
View File
@@ -85,6 +85,7 @@ class BotConfigurationService:
"HELEKET": "🪙 Heleket",
"CLOUDPAYMENTS": "💳 CloudPayments",
"FREEKASSA": "💳 Freekassa",
"KASSA_AI": "💳 KassaAI",
"YOOKASSA": "🟣 YooKassa",
"PLATEGA": "💳 {platega_name}",
"TRIBUTE": "🎁 Tribute",
@@ -143,6 +144,7 @@ class BotConfigurationService:
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
"CLOUDPAYMENTS": "CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.",
"FREEKASSA": "Freekassa: ID магазина, API ключ, секретные слова и вебхуки.",
"KASSA_AI": "KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.",
"PLATEGA": "{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.",
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
@@ -276,6 +278,18 @@ class BotConfigurationService:
"TRAFFIC_MONITORING_INTERVAL_HOURS": "MONITORING",
"TRAFFIC_MONITORED_NODES": "MONITORING",
"TRAFFIC_SNAPSHOT_TTL_HOURS": "MONITORING",
"TRAFFIC_FAST_CHECK_ENABLED": "MONITORING",
"TRAFFIC_FAST_CHECK_INTERVAL_MINUTES": "MONITORING",
"TRAFFIC_FAST_CHECK_THRESHOLD_GB": "MONITORING",
"TRAFFIC_DAILY_CHECK_ENABLED": "MONITORING",
"TRAFFIC_DAILY_CHECK_TIME": "MONITORING",
"TRAFFIC_DAILY_THRESHOLD_GB": "MONITORING",
"TRAFFIC_IGNORED_NODES": "MONITORING",
"TRAFFIC_EXCLUDED_USER_UUIDS": "MONITORING",
"TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES": "MONITORING",
"SUSPICIOUS_NOTIFICATIONS_TOPIC_ID": "MONITORING",
"TRAFFIC_CHECK_BATCH_SIZE": "MONITORING",
"TRAFFIC_CHECK_CONCURRENCY": "MONITORING",
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
"LOGO_FILE": "INTERFACE_BRANDING",
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
@@ -325,6 +339,7 @@ class BotConfigurationService:
"HELEKET_": "HELEKET",
"CLOUDPAYMENTS_": "CLOUDPAYMENTS",
"FREEKASSA_": "FREEKASSA",
"KASSA_AI_": "KASSA_AI",
"PLATEGA_": "PLATEGA",
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
@@ -780,6 +795,57 @@ class BotConfigurationService:
),
"dependencies": "TRAFFIC_MONITORING_ENABLED, Redis",
},
"TRAFFIC_FAST_CHECK_ENABLED": {
"description": (
"Включает быструю проверку трафика. "
"Система сравнивает текущий трафик со snapshot и уведомляет о превышениях дельты."
),
"format": "Булево значение.",
"example": "true",
"warning": "Требует Redis для хранения snapshot. При отключении проверки не выполняются.",
"dependencies": "Redis, TRAFFIC_FAST_CHECK_INTERVAL_MINUTES, TRAFFIC_FAST_CHECK_THRESHOLD_GB",
},
"TRAFFIC_FAST_CHECK_INTERVAL_MINUTES": {
"description": "Интервал быстрой проверки трафика в минутах.",
"format": "Целое число минут (минимум 1).",
"example": "10",
"warning": "Слишком малый интервал создаёт нагрузку на Remnawave API.",
"dependencies": "TRAFFIC_FAST_CHECK_ENABLED",
},
"TRAFFIC_FAST_CHECK_THRESHOLD_GB": {
"description": "Порог дельты трафика в ГБ для быстрой проверки. При превышении отправляется уведомление.",
"format": "Число с плавающей точкой.",
"example": "5.0",
"warning": "Слишком низкий порог приведёт к частым уведомлениям.",
"dependencies": "TRAFFIC_FAST_CHECK_ENABLED",
},
"TRAFFIC_DAILY_CHECK_ENABLED": {
"description": "Включает суточную проверку трафика через bandwidth-stats API.",
"format": "Булево значение.",
"example": "true",
"warning": "Проверка выполняется в указанное время (TRAFFIC_DAILY_CHECK_TIME).",
"dependencies": "TRAFFIC_DAILY_CHECK_TIME, TRAFFIC_DAILY_THRESHOLD_GB",
},
"TRAFFIC_DAILY_CHECK_TIME": {
"description": "Время суточной проверки трафика в формате HH:MM (UTC).",
"format": "Строка времени HH:MM.",
"example": "00:00",
"warning": "Время указывается в UTC.",
"dependencies": "TRAFFIC_DAILY_CHECK_ENABLED",
},
"TRAFFIC_DAILY_THRESHOLD_GB": {
"description": "Порог суточного трафика в ГБ. При превышении за 24 часа отправляется уведомление.",
"format": "Число с плавающей точкой.",
"example": "50.0",
"warning": "Учитывается весь трафик за последние 24 часа.",
"dependencies": "TRAFFIC_DAILY_CHECK_ENABLED",
},
"TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES": {
"description": "Кулдаун уведомлений по одному пользователю в минутах.",
"format": "Целое число минут.",
"example": "60",
"warning": "Защита от спама уведомлениями по одному и тому же пользователю.",
},
}
@classmethod
+57 -3
View File
@@ -33,6 +33,7 @@ from app.database.crud.wheel import (
)
from app.database.crud.user import add_user_balance
from app.database.crud.subscription import get_subscription_by_user_id
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
@@ -339,6 +340,14 @@ class FortuneWheelService:
logger.info(f"📅 Списано {config.spin_cost_days} дней подписки у user_id={user.id}")
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"✅ Списание дней синхронизировано с RemnaWave для user_id={user.id}")
except Exception as e:
logger.error(f"⚠️ Ошибка синхронизации списания дней с RemnaWave: {e}")
return kopeks
async def _apply_prize(
@@ -372,9 +381,46 @@ class FortuneWheelService:
# Дни подписки
subscription = await get_subscription_by_user_id(db, user.id)
if subscription:
subscription.end_date += timedelta(days=prize.prize_value)
subscription.updated_at = datetime.utcnow()
logger.info(f"📅 Начислено {prize.prize_value} дней подписки user_id={user.id}")
# Проверяем суточный тариф - для него конвертируем дни в баланс
is_daily = getattr(subscription, 'is_daily', False) or (
subscription.tariff and getattr(subscription.tariff, 'is_daily', False)
)
if is_daily:
# Для суточных тарифов: дни * суточная_цена = баланс
daily_price = 0
if subscription.tariff and hasattr(subscription.tariff, 'daily_price_kopeks'):
daily_price = subscription.tariff.daily_price_kopeks or 0
if daily_price > 0:
balance_bonus = prize.prize_value * daily_price
await add_user_balance(
db, user, balance_bonus,
description=f"Выигрыш в колесе удачи: {prize.prize_value} дней → {balance_bonus/100:.2f}",
create_transaction=True,
)
logger.info(f"💰 Суточный тариф: {prize.prize_value} дней конвертированы в {balance_bonus/100:.2f}₽ для user_id={user.id}")
else:
# Если нет цены - используем prize_value_kopeks
await add_user_balance(
db, user, prize.prize_value_kopeks,
description=f"Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)",
create_transaction=True,
)
logger.info(f"💰 Дни конвертированы в баланс для user_id={user.id}")
else:
# Обычная подписка - добавляем дни и синхронизируем с RemnaWave
subscription.end_date += timedelta(days=prize.prize_value)
subscription.updated_at = datetime.utcnow()
logger.info(f"📅 Начислено {prize.prize_value} дней подписки user_id={user.id}")
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"✅ Синхронизировано с RemnaWave для user_id={user.id}")
except Exception as e:
logger.error(f"⚠️ Ошибка синхронизации с RemnaWave: {e}")
else:
# Если нет подписки - начисляем на баланс эквивалент
await add_user_balance(
@@ -392,6 +438,14 @@ class FortuneWheelService:
subscription.traffic_limit_gb += prize.prize_value
subscription.updated_at = datetime.utcnow()
logger.info(f"📊 Начислено {prize.prize_value}GB трафика user_id={user.id}")
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"✅ Трафик синхронизирован с RemnaWave для user_id={user.id}")
except Exception as e:
logger.error(f"⚠️ Ошибка синхронизации трафика с RemnaWave: {e}")
else:
# Если безлимит или нет подписки - на баланс
await add_user_balance(
+1
View File
@@ -134,6 +134,7 @@ class AdminStates(StatesGroup):
editing_faq_title = State()
editing_faq_content = State()
editing_notification_value = State()
editing_traffic_setting = State()
confirming_sync = State()
+58 -6
View File
@@ -9,6 +9,12 @@ from app.localization.texts import get_texts
LOGO_PATH = Path(settings.LOGO_FILE)
_PRIVACY_RESTRICTED_CODE = "BUTTON_USER_PRIVACY_RESTRICTED"
_TOPIC_REQUIRED_ERRORS = (
"topic must be specified",
"TOPIC_CLOSED",
"TOPIC_DELETED",
"FORUM_CLOSED",
)
def is_qr_message(message: Message) -> bool:
@@ -80,6 +86,15 @@ def is_privacy_restricted_error(error: Exception) -> bool:
return _PRIVACY_RESTRICTED_CODE in message or _PRIVACY_RESTRICTED_CODE in description
def is_topic_required_error(error: Exception) -> bool:
"""Проверяет, является ли ошибка связанной с топиками/форумами."""
if not isinstance(error, TelegramBadRequest):
return False
description = str(error).lower()
return any(err.lower() in description for err in _TOPIC_REQUIRED_ERRORS)
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
if not settings.ENABLE_LOGO_MODE:
@@ -97,15 +112,38 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
# Канал с топиками — просто игнорируем, нельзя ответить без message_thread_id
return None
if is_privacy_restricted_error(error):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
return await _original_answer(self, fallback_text, **safe_kwargs)
try:
return await _original_answer(self, fallback_text, **safe_kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст
return await _original_answer(self, text, **kwargs)
try:
return await _original_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
except Exception:
return await _original_answer(self, text, **kwargs)
return await _original_answer(self, text, **kwargs)
try:
return await _original_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
try:
return await _original_answer(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
raise
async def _edit_with_photo(self: Message, text: str, **kwargs):
@@ -142,6 +180,8 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
try:
return await self.edit_media(InputMediaPhoto(**media_kwargs), **edit_kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
if is_privacy_restricted_error(error):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
@@ -149,17 +189,29 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
await self.delete()
except Exception:
pass
return await _original_answer(self, fallback_text, **safe_kwargs)
try:
return await _original_answer(self, fallback_text, **safe_kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Фоллбек: удалим и отправим обычный текст без фото
try:
await self.delete()
except Exception:
pass
return await _original_answer(self, text, **kwargs)
try:
return await _original_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Обработка ошибок MESSAGE_ID_INVALID для сообщений без фото
try:
return await _original_edit_text(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
if "MESSAGE_ID_INVALID" in str(error) or "message to edit not found" in str(error).lower():
# Сообщение удалено или недоступно — просто игнорируем
return None
+4 -1
View File
@@ -184,7 +184,10 @@ def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[
if not settings.is_devices_selection_enabled():
forced_limit = settings.get_disabled_mode_device_limit()
return forced_limit
if forced_limit is not None:
return forced_limit
# Если forced_limit не задан, используем device_limit из подписки
# чтобы при смене тарифа лимит устройств обновлялся в панели
limit = getattr(subscription, "device_limit", None)
if limit is None or limit <= 0:
+60 -2
View File
@@ -209,6 +209,17 @@ router = APIRouter()
promo_code_service = PromoCodeService()
renewal_service = SubscriptionRenewalService()
# Кешированный Bot для проверки подписки на канал (снижает нагрузку)
_channel_check_bot: Optional[Bot] = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
_CRYPTOBOT_MIN_USD = 1.0
_CRYPTOBOT_MAX_USD = 1000.0
@@ -3089,6 +3100,18 @@ async def get_subscription_details(
payload: MiniAppSubscriptionRequest,
db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionResponse:
# Check maintenance mode first
if maintenance_service.is_maintenance_active():
status_info = maintenance_service.get_status_info()
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "maintenance",
"message": maintenance_service.get_maintenance_message() or "Service is under maintenance",
"reason": status_info.get("reason"),
},
)
try:
webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
except TelegramWebAppAuthError as error:
@@ -3112,6 +3135,31 @@ async def get_subscription_details(
detail="Invalid Telegram user identifier",
) from None
# Check required channel subscription
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
try:
bot = _get_channel_check_bot()
chat_member = await bot.get_chat_member(
chat_id=settings.CHANNEL_SUB_ID,
user_id=telegram_id
)
# Не закрываем сессию - бот переиспользуется
if chat_member.status not in ["member", "administrator", "creator"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "channel_subscription_required",
"message": "Please subscribe to our channel to continue",
"channel_link": settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Failed to check channel subscription for user {telegram_id}: {e}")
# Don't block user if check fails
user = await get_user_by_telegram_id(db, telegram_id)
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
@@ -3367,6 +3415,7 @@ async def get_subscription_details(
subscription_url: Optional[str] = None
subscription_crypto_link: Optional[str] = None
happ_redirect_link: Optional[str] = None
hide_subscription_link: bool = False
remnawave_short_uuid: Optional[str] = None
status_actual = "missing"
subscription_status_value = "none"
@@ -3381,6 +3430,8 @@ async def get_subscription_details(
status_actual = subscription.actual_status
subscription_status_value = subscription.status
links_payload = await _load_subscription_links(subscription)
# Флаг скрытия ссылки (скрывается только текст, кнопки работают)
hide_subscription_link = settings.should_hide_subscription_link()
subscription_url = (
links_payload.get("subscription_url") or subscription.subscription_url
)
@@ -3533,6 +3584,7 @@ async def get_subscription_details(
remnawave_short_uuid=remnawave_short_uuid,
user=response_user,
subscription_url=subscription_url,
hide_subscription_link=hide_subscription_link,
subscription_crypto_link=subscription_crypto_link,
subscription_purchase_url=purchase_url or None,
links=links,
@@ -3543,7 +3595,7 @@ async def get_subscription_details(
connected_devices=devices,
happ=links_payload.get("happ") if subscription else None,
happ_link=links_payload.get("happ_link") if subscription else None,
happ_crypto_link=links_payload.get("happ_crypto_link") if subscription else None,
happ_crypto_link=subscription_crypto_link, # Используем уже вычисленное значение с fallback
happ_cryptolink_redirect_link=happ_redirect_link,
happ_cryptolink_redirect_template=settings.get_happ_cryptolink_redirect_template(),
balance_kopeks=user.balance_kopeks,
@@ -6738,8 +6790,14 @@ async def purchase_tariff_endpoint(
await db.refresh(subscription)
# Синхронизируем с RemnaWave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
await service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="покупка тарифа (miniapp)",
)
# Сохраняем корзину для автопродления
try:
+10 -6
View File
@@ -51,25 +51,29 @@ async def verify_websocket_token(
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint для real-time обновлений."""
client_host = websocket.client.host if websocket.client else "unknown"
logger.info("WebSocket connection attempt from %s", client_host)
logger.debug("WebSocket connection attempt from %s", client_host)
# Сначала проверяем авторизацию ДО принятия соединения
token = websocket.query_params.get("token") or websocket.query_params.get("api_key")
if not token:
logger.warning("WebSocket: No token provided from %s", client_host)
logger.debug("WebSocket: No token provided from %s", client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: No token provided")
return
if not await verify_websocket_token(websocket, token):
logger.warning("WebSocket: Invalid token from %s", client_host)
logger.debug("WebSocket: Invalid token from %s", client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: Invalid token")
return
# Только после успешной проверки принимаем соединение
try:
await websocket.accept()
logger.info("WebSocket connection accepted from %s", client_host)
logger.debug("WebSocket connection accepted from %s", client_host)
except Exception as e:
logger.error("WebSocket: Failed to accept connection from %s: %s", client_host, e)
return
@@ -104,7 +108,7 @@ async def websocket_endpoint(websocket: WebSocket):
logger.exception("Error processing WebSocket message: %s", error)
except WebSocketDisconnect:
logger.info("WebSocket client disconnected")
logger.debug("WebSocket client disconnected")
except Exception as error:
logger.exception("WebSocket error: %s", error)
finally:
+1
View File
@@ -696,6 +696,7 @@ class MiniAppSubscriptionResponse(BaseModel):
user: MiniAppSubscriptionUser
traffic_purchases: List[MiniAppTrafficPurchase] = Field(default_factory=list)
subscription_url: Optional[str] = None
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
subscription_crypto_link: Optional[str] = None
subscription_purchase_url: Optional[str] = None
links: List[str] = Field(default_factory=list)
+32 -1
View File
@@ -25,14 +25,45 @@ class WebAPIServer:
logger.warning("WEB_API_WORKERS > 1 не поддерживается в embed-режиме, используем 1")
workers = 1
# Кастомный конфиг логирования - скрываем спам от WebSocket
log_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(message)s",
"use_colors": None,
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": "WARNING", "propagate": False},
"uvicorn.error": {"level": "WARNING", "propagate": False},
"uvicorn.access": {"level": "ERROR", "propagate": False},
"uvicorn.protocols": {"level": "WARNING", "propagate": False},
"uvicorn.protocols.websockets": {"level": "WARNING", "propagate": False},
"uvicorn.protocols.websockets.websockets_impl": {"level": "WARNING", "propagate": False},
"websockets": {"level": "WARNING", "propagate": False},
"websockets.server": {"level": "WARNING", "propagate": False},
},
}
self._config = uvicorn.Config(
app=self._app,
host=settings.WEB_API_HOST,
port=int(settings.WEB_API_PORT or 8080),
log_level=settings.LOG_LEVEL.lower(),
log_level="warning",
workers=workers,
lifespan="on",
access_log=False,
log_config=log_config,
)
self._server = uvicorn.Server(self._config)
self._task: Optional[asyncio.Task[None]] = None
+169 -57
View File
@@ -679,18 +679,39 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
@router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + "/check")
async def cloudpayments_check_webhook(request: Request) -> JSONResponse:
"""Check webhook - вызывается перед списанием, можно отклонить платёж."""
raw_body = await request.body()
try:
raw_body = await request.body()
# Проверяем подпись
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
if settings.CLOUDPAYMENTS_API_SECRET and not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
logger.warning("CloudPayments check webhook: invalid signature")
return JSONResponse({"code": 13}) # Отклонить
# Логируем для диагностики
logger.info(
"CloudPayments check webhook received, body_len=%d, all_headers=%s",
len(raw_body),
dict(request.headers),
)
# Разрешаем платёж
return JSONResponse({"code": 0})
# Проверяем подпись только если она пришла и API_SECRET настроен
# CloudPayments использует заголовок X-Content-HMAC или Content-HMAC
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
if settings.CLOUDPAYMENTS_API_SECRET and signature:
if not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
logger.warning(
"CloudPayments check webhook: invalid signature, sig=%s...",
signature[:20] if signature else "empty",
)
return JSONResponse({"code": 13}) # Отклонить
elif settings.CLOUDPAYMENTS_API_SECRET and not signature:
# Подпись не пришла, но API_SECRET настроен - пропускаем проверку с предупреждением
logger.warning("CloudPayments check webhook: no signature header, skipping verification")
# Разрешаем платёж
logger.info("CloudPayments check webhook: allowing payment, returning code=0")
return JSONResponse({"code": 0})
except Exception as e:
logger.exception("CloudPayments check webhook error: %s", e)
# В случае ошибки всё равно разрешаем платёж
return JSONResponse({"code": 0})
# CloudPayments Pay webhook (успешная оплата)
@router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + "/pay")
@@ -698,13 +719,14 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"""Pay webhook - вызывается после успешной оплаты."""
raw_body = await request.body()
# Проверяем подпись
# Проверяем подпись только если она пришла и API_SECRET настроен
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
if settings.CLOUDPAYMENTS_API_SECRET and not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
logger.warning("CloudPayments pay webhook: invalid signature")
return JSONResponse({"code": 13})
if settings.CLOUDPAYMENTS_API_SECRET and signature:
if not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
logger.warning("CloudPayments pay webhook: invalid signature")
return JSONResponse({"code": 13})
# Парсим данные формы
try:
@@ -729,13 +751,14 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"""Fail webhook - вызывается при неуспешной оплате."""
raw_body = await request.body()
# Проверяем подпись
# Проверяем подпись только если она пришла и API_SECRET настроен
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
if settings.CLOUDPAYMENTS_API_SECRET and not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
logger.warning("CloudPayments fail webhook: invalid signature")
return JSONResponse({"code": 13})
if settings.CLOUDPAYMENTS_API_SECRET and signature:
if not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
logger.warning("CloudPayments fail webhook: invalid signature")
return JSONResponse({"code": 13})
# Парсим данные формы
try:
@@ -758,43 +781,60 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
@router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH)
async def cloudpayments_webhook(request: Request) -> JSONResponse:
"""Универсальный webhook endpoint."""
raw_body = await request.body()
# Проверяем подпись
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
if settings.CLOUDPAYMENTS_API_SECRET and not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
logger.warning("CloudPayments webhook: invalid signature")
return JSONResponse({"code": 13})
# Парсим данные формы
try:
form_data = await request.form()
webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data))
except Exception as error:
logger.error("CloudPayments webhook parse error: %s", error)
raw_body = await request.body()
# Логируем для диагностики
logger.info(
"CloudPayments universal webhook received, body_len=%d, headers=%s",
len(raw_body),
dict(request.headers),
)
# Проверяем подпись только если она пришла и API_SECRET настроен
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
if settings.CLOUDPAYMENTS_API_SECRET and signature:
if not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
logger.warning("CloudPayments webhook: invalid signature")
return JSONResponse({"code": 13})
# Парсим данные формы
try:
form_data = await request.form()
webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data))
logger.info("CloudPayments webhook parsed data: %s", webhook_data)
except Exception as error:
logger.error("CloudPayments webhook parse error: %s", error)
# Может быть это Check уведомление - просто разрешаем
return JSONResponse({"code": 0})
# Определяем тип webhook по статусу
status_value = webhook_data.get("status", "")
if status_value in ("Completed", "Authorized"):
# Успешная оплата
await _process_payment_service_callback(
payment_service,
webhook_data,
"process_cloudpayments_pay_webhook",
)
elif status_value in ("Declined", "Cancelled"):
# Неуспешная оплата
await _process_payment_service_callback(
payment_service,
webhook_data,
"process_cloudpayments_fail_webhook",
)
else:
# Check или другой тип уведомления - просто разрешаем
logger.info("CloudPayments webhook: status=%s, allowing (code=0)", status_value)
return JSONResponse({"code": 0})
except Exception as e:
logger.exception("CloudPayments universal webhook error: %s", e)
return JSONResponse({"code": 0})
# Определяем тип webhook по статусу
status_value = webhook_data.get("status", "")
if status_value in ("Completed", "Authorized"):
# Успешная оплата
await _process_payment_service_callback(
payment_service,
webhook_data,
"process_cloudpayments_pay_webhook",
)
elif status_value in ("Declined", "Cancelled"):
# Неуспешная оплата
await _process_payment_service_callback(
payment_service,
webhook_data,
"process_cloudpayments_fail_webhook",
)
return JSONResponse({"code": 0})
routes_registered = True
@@ -892,6 +932,77 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
# KassaAI webhook
if settings.is_kassa_ai_enabled():
@router.get(settings.KASSA_AI_WEBHOOK_PATH)
async def kassa_ai_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "kassa_ai_webhook",
"enabled": settings.is_kassa_ai_enabled(),
}
)
@router.post(settings.KASSA_AI_WEBHOOK_PATH)
async def kassa_ai_webhook(request: Request) -> Response:
# Получаем данные формы
try:
form_data = await request.form()
except Exception:
logger.error("KassaAI webhook: не удалось прочитать данные формы")
return Response("Error reading form data", status_code=status.HTTP_400_BAD_REQUEST)
# Извлекаем параметры (те же что и у Freekassa)
merchant_id = form_data.get("MERCHANT_ID")
amount = form_data.get("AMOUNT")
order_id = form_data.get("MERCHANT_ORDER_ID")
sign = form_data.get("SIGN")
intid = form_data.get("intid")
cur_id = form_data.get("CUR_ID")
if not all([merchant_id, amount, order_id, sign, intid]):
logger.warning("KassaAI webhook: отсутствуют обязательные параметры")
return Response("Missing parameters", status_code=status.HTTP_400_BAD_REQUEST)
try:
merchant_id_int = int(merchant_id)
amount_float = float(amount)
cur_id_int = int(cur_id) if cur_id else None
except (ValueError, TypeError) as e:
logger.error("KassaAI webhook: некорректные параметры - %s", e)
return Response("Invalid parameters", status_code=status.HTTP_400_BAD_REQUEST)
# Обрабатываем webhook
db_generator = get_db()
try:
db = await db_generator.__anext__()
except StopAsyncIteration:
return Response("DB Error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
try:
success = await payment_service.process_kassa_ai_webhook(
db,
merchant_id=merchant_id_int,
amount=amount_float,
order_id=order_id,
sign=sign,
intid=intid,
cur_id=cur_id_int,
)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
if success:
return Response("YES", status_code=status.HTTP_200_OK)
return Response("Error", status_code=status.HTTP_400_BAD_REQUEST)
routes_registered = True
if routes_registered:
@router.get("/health/payment-webhooks")
async def payment_webhooks_health() -> JSONResponse:
@@ -908,6 +1019,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"platega_enabled": settings.is_platega_enabled(),
"cloudpayments_enabled": settings.is_cloudpayments_enabled(),
"freekassa_enabled": settings.is_freekassa_enabled(),
"kassa_ai_enabled": settings.is_kassa_ai_enabled(),
}
)
+1 -1
View File
@@ -6,7 +6,7 @@
- `GET /contests/daily/templates?enabled_only=false` — список шаблонов игр.
- `GET /contests/daily/templates/{id}` — получить шаблон.
- `PATCH /contests/daily/templates/{id}` — обновить поля: `name`, `description`, `prize_days`, `max_winners`, `attempts_per_user`, `times_per_day`, `schedule_times`, `cooldown_hours`, `payload` (dict), `is_enabled`.
- `PATCH /contests/daily/templates/{id}` — обновить поля: `name`, `description`, `prize_type`, `prize_value`, `max_winners`, `attempts_per_user`, `times_per_day`, `schedule_times`, `cooldown_hours`, `payload` (dict), `is_enabled`.
- `POST /contests/daily/templates/{id}/start-round` — запустить раунд вручную. Тело:
```json
{
+7 -3
View File
@@ -148,6 +148,10 @@ async def main():
logging.getLogger("aiogram").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
# Скрываем спам от WebSocket подключений (connection open/closed)
logging.getLogger("uvicorn.protocols.websockets.websockets_impl").setLevel(logging.WARNING)
logging.getLogger("websockets.server").setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
timeline = StartupTimeline(logger, "Bedolaga Remnawave Bot")
@@ -578,7 +582,7 @@ async def main():
await bot.set_webhook(
url=webhook_url,
secret_token=settings.WEBHOOK_SECRET_TOKEN,
drop_pending_updates=settings.WEBHOOK_DROP_PENDING_UPDATES,
drop_pending_updates=False, # Обрабатываем накопившиеся обновления
allowed_updates=allowed_updates,
)
stage.log(f"Webhook установлен: {webhook_url}")
@@ -664,8 +668,8 @@ async def main():
success_message="Aiogram polling запущен",
) as stage:
if polling_enabled:
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
stage.log("skip_updates=True")
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=False))
stage.log("skip_updates=False — накопившиеся обновления будут обработаны")
else:
polling_task = None
stage.skip("Polling отключен режимом работы")
+1
View File
@@ -14,6 +14,7 @@ redis==5.0.1
PyYAML==6.0.2
fastapi==0.115.6
uvicorn==0.32.1
websockets>=12.0
python-multipart==0.0.9
# YooKassa SDK
+1
View File
@@ -0,0 +1 @@
# Middlewares tests package
@@ -0,0 +1,263 @@
"""Тесты для функций сохранения/получения pending_start_payload в channel_checker."""
from pathlib import Path
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch, create_autospec
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from aiogram.types import Message, User
class TestRedisPayloadFunctions:
"""Тесты для Redis-функций сохранения payload."""
async def test_save_pending_payload_to_redis_success(self, monkeypatch):
"""Тест успешного сохранения payload в Redis."""
from app.middlewares import channel_checker
mock_redis = AsyncMock()
mock_redis.set = AsyncMock(return_value=True)
mock_redis.aclose = AsyncMock()
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
result = await channel_checker.save_pending_payload_to_redis(123456, "ref_test123")
assert result is True
mock_redis.set.assert_awaited_once()
call_args = mock_redis.set.await_args
assert "pending_start_payload:123456" in call_args.args[0]
assert call_args.args[1] == "ref_test123"
assert call_args.kwargs.get("ex") == 3600
mock_redis.aclose.assert_awaited_once()
async def test_save_pending_payload_to_redis_failure(self, monkeypatch):
"""Тест обработки ошибки при сохранении в Redis."""
from app.middlewares import channel_checker
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
mock_aioredis.from_url = MagicMock(side_effect=Exception("Redis connection failed"))
result = await channel_checker.save_pending_payload_to_redis(123456, "ref_test123")
assert result is False
async def test_get_pending_payload_from_redis_success(self, monkeypatch):
"""Тест успешного получения payload из Redis."""
from app.middlewares import channel_checker
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=b"ref_test123")
mock_redis.aclose = AsyncMock()
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
result = await channel_checker.get_pending_payload_from_redis(123456)
assert result == "ref_test123"
mock_redis.get.assert_awaited_once()
mock_redis.aclose.assert_awaited_once()
async def test_get_pending_payload_from_redis_not_found(self, monkeypatch):
"""Тест когда payload не найден в Redis."""
from app.middlewares import channel_checker
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=None)
mock_redis.aclose = AsyncMock()
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
result = await channel_checker.get_pending_payload_from_redis(123456)
assert result is None
async def test_get_pending_payload_from_redis_failure(self, monkeypatch):
"""Тест обработки ошибки при получении из Redis."""
from app.middlewares import channel_checker
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
mock_aioredis.from_url = MagicMock(side_effect=Exception("Redis connection failed"))
result = await channel_checker.get_pending_payload_from_redis(123456)
assert result is None
async def test_delete_pending_payload_from_redis(self, monkeypatch):
"""Тест удаления payload из Redis."""
from app.middlewares import channel_checker
mock_redis = AsyncMock()
mock_redis.delete = AsyncMock(return_value=1)
mock_redis.aclose = AsyncMock()
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
# Не должно бросать исключение
await channel_checker.delete_pending_payload_from_redis(123456)
mock_redis.delete.assert_awaited_once()
async def test_delete_pending_payload_from_redis_handles_error(self, monkeypatch):
"""Тест что удаление не бросает исключение при ошибке."""
from app.middlewares import channel_checker
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
mock_aioredis.from_url = MagicMock(side_effect=Exception("Redis error"))
# Не должно бросать исключение
await channel_checker.delete_pending_payload_from_redis(123456)
def _create_mock_message(text: str, user_id: int):
"""Создаёт мок Message с нужными атрибутами."""
mock_msg = MagicMock(spec=Message)
mock_msg.text = text
mock_msg.from_user = SimpleNamespace(id=user_id)
return mock_msg
class TestCaptureStartPayload:
"""Тесты для метода _capture_start_payload."""
async def test_capture_saves_to_fsm_state(self, monkeypatch):
"""Тест сохранения payload в FSM state."""
from app.middlewares.channel_checker import ChannelCheckerMiddleware
middleware = ChannelCheckerMiddleware()
mock_state = AsyncMock()
mock_state.get_data = AsyncMock(return_value={})
mock_state.set_data = AsyncMock()
mock_message = _create_mock_message("/start ref_abc123", 123456)
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
await middleware._capture_start_payload(mock_state, mock_message, None)
mock_state.set_data.assert_awaited_once()
saved_data = mock_state.set_data.await_args.args[0]
assert saved_data["pending_start_payload"] == "ref_abc123"
# Также должен сохраняться в Redis
mock_save_redis.assert_awaited_once_with(123456, "ref_abc123")
async def test_capture_saves_to_redis_when_state_none(self, monkeypatch):
"""Тест сохранения payload в Redis когда FSM state недоступен."""
from app.middlewares.channel_checker import ChannelCheckerMiddleware
middleware = ChannelCheckerMiddleware()
mock_message = _create_mock_message("/start ref_xyz789", 999888)
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
await middleware._capture_start_payload(None, mock_message, None)
# Должен сохраняться в Redis даже если state=None
mock_save_redis.assert_awaited_once_with(999888, "ref_xyz789")
async def test_capture_ignores_message_without_payload(self, monkeypatch):
"""Тест что сообщение без payload игнорируется."""
from app.middlewares.channel_checker import ChannelCheckerMiddleware
middleware = ChannelCheckerMiddleware()
mock_state = AsyncMock()
mock_state.get_data = AsyncMock(return_value={})
mock_state.set_data = AsyncMock()
mock_message = _create_mock_message("/start", 123456) # Без payload
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
await middleware._capture_start_payload(mock_state, mock_message, None)
mock_state.set_data.assert_not_awaited()
mock_save_redis.assert_not_awaited()
async def test_capture_ignores_non_start_message(self, monkeypatch):
"""Тест что не-start сообщения игнорируются."""
from app.middlewares.channel_checker import ChannelCheckerMiddleware
middleware = ChannelCheckerMiddleware()
mock_state = AsyncMock()
mock_state.get_data = AsyncMock(return_value={})
mock_state.set_data = AsyncMock()
mock_message = _create_mock_message("/help something", 123456) # Не /start
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
await middleware._capture_start_payload(mock_state, mock_message, None)
mock_state.set_data.assert_not_awaited()
mock_save_redis.assert_not_awaited()
async def test_capture_does_not_overwrite_same_payload(self, monkeypatch):
"""Тест что одинаковый payload не перезаписывается в FSM state."""
from app.middlewares.channel_checker import ChannelCheckerMiddleware
middleware = ChannelCheckerMiddleware()
mock_state = AsyncMock()
mock_state.get_data = AsyncMock(return_value={"pending_start_payload": "ref_same"})
mock_state.set_data = AsyncMock()
mock_message = _create_mock_message("/start ref_same", 123456) # Тот же payload
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
await middleware._capture_start_payload(mock_state, mock_message, None)
# FSM state не должен перезаписываться
mock_state.set_data.assert_not_awaited()
# Но в Redis всё равно сохраняем (для надёжности)
mock_save_redis.assert_awaited_once()
class TestPayloadIntegration:
"""Интеграционные тесты для потока сохранения/восстановления payload."""
async def test_full_flow_fsm_state_works(self, monkeypatch):
"""Тест полного потока когда FSM state работает корректно."""
from app.middlewares.channel_checker import ChannelCheckerMiddleware
middleware = ChannelCheckerMiddleware()
# Сохраняем payload
state_storage = {}
mock_state = AsyncMock()
mock_state.get_data = AsyncMock(return_value=state_storage)
mock_state.set_data = AsyncMock(side_effect=lambda d: state_storage.update(d))
mock_message = _create_mock_message("/start ref_flow_test", 111222)
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock):
await middleware._capture_start_payload(mock_state, mock_message, None)
# Проверяем что payload сохранён
assert state_storage.get("pending_start_payload") == "ref_flow_test"
async def test_payload_retrieved_from_redis_fallback(self, monkeypatch):
"""Тест что payload восстанавливается из Redis если в FSM state его нет."""
from app.middlewares.channel_checker import get_pending_payload_from_redis
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=b"ref_from_redis")
mock_redis.aclose = AsyncMock()
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
result = await get_pending_payload_from_redis(333444)
assert result == "ref_from_redis"