Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60305d8d5b | |||
| 07ef3b46d9 | |||
| f9d58e964c | |||
| d3c14ac303 | |||
| fda9f3beec | |||
| 27365b3c75 | |||
| 3dac332a9f | |||
| c5124b97b6 | |||
| ee2e79db31 | |||
| d05ff678ab | |||
| fcaa9dfb27 | |||
| c30c2feee1 | |||
| 640da34736 | |||
| 93bb8e0eb4 | |||
| 7d9ced8f4f | |||
| b5998ea9d2 | |||
| aabadf1ffd | |||
| e5e5bb3354 | |||
| ea41b0af7a | |||
| 3193ffbd1b | |||
| 5da01cc6df | |||
| 887ea9cf5a | |||
| bee4aa4284 | |||
| b167ed3dd1 | |||
| 6cec024e46 | |||
| 2094886990 | |||
| b0fd38d60c | |||
| 3a680b41b0 | |||
| 02e40bd6f7 | |||
| 19dabf3851 | |||
| eaf3a07579 | |||
| be1da976e1 | |||
| a1ffd5bda6 | |||
| d58a80f3ea | |||
| 45c7afe34c | |||
| e43a8d6ce4 | |||
| e94b93d0c1 | |||
| 2ad26a9156 | |||
| 3383b9c790 | |||
| 6acaf18203 | |||
| 019fbc12b6 | |||
| 5156d635f0 | |||
| f77922522a | |||
| 0db00a8f90 | |||
| fe54640885 | |||
| ec8eaf52bf | |||
| fe5f5ded96 | |||
| 2cb6d731e9 | |||
| 184c52d4ea | |||
| 8e85e244cb | |||
| 43a326a98c | |||
| d9de15a5a0 | |||
| 17ce64037f | |||
| 79793c47bb | |||
| 7091eb9c14 | |||
| dc1e96bbe9 | |||
| 1e37fd9dd2 | |||
| 9aa22af339 | |||
| 26637f0ae5 | |||
| 6d67cad3e7 | |||
| 90d9df8f0e | |||
| ef654a09bb | |||
| 74f3b388d8 | |||
| 5af1f358b2 | |||
| 994325360c | |||
| f0e7f8e3be | |||
| 40d8a6dc8b |
@@ -187,6 +187,16 @@ REMNAWAVE_AUTO_SYNC_ENABLED=false
|
||||
# Времена синхронизации (через запятую, формат HH:MM по МСК)
|
||||
REMNAWAVE_AUTO_SYNC_TIMES=03:00
|
||||
|
||||
# ===== REMNAWAVE WEBHOOKS (входящие события из панели) =====
|
||||
# Включить приём вебхуков от панели Remnawave (real-time события)
|
||||
REMNAWAVE_WEBHOOK_ENABLED=false
|
||||
# Путь для приёма вебхуков (должен совпадать с настройкой в панели)
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Общий секрет для подписи HMAC-SHA256 (минимум 32 символа)
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
|
||||
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
|
||||
# Тег для пробных пользователей (опционально)
|
||||
# TRIAL_USER_TAG=TRIAL
|
||||
@@ -536,6 +546,8 @@ MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
# Ожидаемый origin для iframe (опционально, для безопасности)
|
||||
# MULENPAY_IFRAME_EXPECTED_ORIGIN=https://mulenpay.ru
|
||||
# URL для редиректа после оплаты (по умолчанию WEBHOOK_URL)
|
||||
# MULENPAY_WEBSITE_URL=https://your-cabinet-url.com
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.9.0"
|
||||
".": "3.10.2"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
## [3.10.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.1...v3.10.2) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
|
||||
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
|
||||
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
|
||||
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
|
||||
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
|
||||
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
|
||||
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
|
||||
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
|
||||
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
|
||||
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
|
||||
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
|
||||
|
||||
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
|
||||
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
|
||||
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
|
||||
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
|
||||
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
|
||||
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
|
||||
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
|
||||
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
|
||||
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
|
||||
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
|
||||
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
|
||||
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
|
||||
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
|
||||
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
|
||||
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
|
||||
|
||||
## [3.10.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.1...v3.10.0) (2026-02-10)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
|
||||
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
|
||||
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
|
||||
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
|
||||
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
|
||||
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
|
||||
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
|
||||
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
|
||||
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
|
||||
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
|
||||
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
|
||||
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
|
||||
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
|
||||
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
|
||||
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
|
||||
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
|
||||
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
|
||||
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
|
||||
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
|
||||
|
||||
## [3.9.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.0...v3.9.1) (2026-02-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
|
||||
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
|
||||
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
|
||||
|
||||
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.9.0" # x-release-please-version
|
||||
ARG VERSION="v3.10.2" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -610,6 +610,16 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
handle /app-config.json {
|
||||
header Access-Control-Allow-Origin "*"
|
||||
@@ -818,6 +828,18 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
@@ -1055,6 +1077,102 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=secret_key_name
|
||||
```
|
||||
|
||||
### 📡 Вебхуки Remnawave (real-time события)
|
||||
|
||||
Бот может принимать входящие вебхуки от панели Remnawave для мгновенной реакции на события подписок. Это значительно улучшает скорость обновления данных по сравнению с периодической синхронизацией.
|
||||
|
||||
#### Поддерживаемые события
|
||||
|
||||
| Событие | Описание |
|
||||
|---------|----------|
|
||||
| `user.expired` | Подписка истекла |
|
||||
| `user.disabled` | Подписка деактивирована |
|
||||
| `user.enabled` | Подписка активирована |
|
||||
| `user.limited` | Превышен лимит трафика |
|
||||
| `user.traffic_reset` | Трафик сброшен |
|
||||
| `user.modified` | Данные подписки изменены (трафик, дата, URL) |
|
||||
| `user.deleted` | Пользователь удалён |
|
||||
| `user.revoked` | Ключи подписки отозваны |
|
||||
| `user.created` | Пользователь создан |
|
||||
| `user.expires_in_*` | Предупреждения об истечении (72ч, 48ч, 24ч) |
|
||||
| `user.first_connected` | Первое подключение |
|
||||
| `user.bandwidth_usage_threshold_reached` | Порог трафика достигнут |
|
||||
| `user_hwid_devices.*` | Устройство добавлено/удалено |
|
||||
| `node.*`, `service.*` | Административные события (ноды, сервис) |
|
||||
|
||||
#### Настройка
|
||||
|
||||
**1. Переменные окружения в `.env`:**
|
||||
|
||||
```env
|
||||
REMNAWAVE_WEBHOOK_ENABLED=true
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
REMNAWAVE_WEBHOOK_SECRET=your_secret_min_32_chars_here
|
||||
```
|
||||
|
||||
Сгенерируйте секрет:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**2. Настройка в панели Remnawave:**
|
||||
|
||||
В env панели Remnawave заполните:
|
||||
|
||||
- **URL**: `https://hooks.domain.com/remnawave-webhook`
|
||||
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
|
||||
|
||||
**3. Настройка прокси:**
|
||||
|
||||
Добавьте путь `/remnawave-webhook` в конфигурацию обратного прокси.
|
||||
|
||||
**Caddy:**
|
||||
|
||||
```caddy
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nginx:**
|
||||
|
||||
```nginx
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
**4. Проверка работоспособности:**
|
||||
|
||||
```bash
|
||||
# Health-check (GET запрос)
|
||||
curl -s https://hooks.domain.com/remnawave-webhook | jq
|
||||
|
||||
# Ожидаемый ответ:
|
||||
# {"status": "ok", "service": "remnawave_webhook", "enabled": true}
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
- Секрет должен быть не менее 32 символов
|
||||
- Бот верифицирует подпись `X-Remnawave-Signature` (HMAC-SHA256) для каждого запроса
|
||||
- При включённых вебхуках бот автоматически защищает подписки от перезаписи данными из периодической синхронизации в течение 60 секунд после получения события
|
||||
- Если бот и панель на одном сервере, URL вебхука может быть `http://remnawave_bot:8080/remnawave-webhook` (внутри Docker-сети)
|
||||
|
||||
### 💳 Freekassa
|
||||
|
||||
Платёжный провайдер [Freekassa](https://freekassa.ru) поддерживает NSPK СБП и банковские карты.
|
||||
|
||||
@@ -63,7 +63,6 @@ from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
@@ -124,10 +123,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.database.crud.user import (
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
TrafficPurchase,
|
||||
Transaction,
|
||||
@@ -1806,6 +1807,8 @@ async def reset_user_trial(
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
@@ -1876,6 +1879,8 @@ async def reset_user_subscription(
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -969,14 +970,13 @@ async def request_email_change(
|
||||
"""
|
||||
Request email change.
|
||||
|
||||
Sends a 6-digit verification code to the new email address.
|
||||
User must have a verified email to change it.
|
||||
For verified emails: sends a 6-digit verification code to the new email.
|
||||
For unverified emails: replaces the email directly and sends verification to the new address.
|
||||
"""
|
||||
# Check if user has a verified email
|
||||
if not user.email or not user.email_verified:
|
||||
if not user.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You must have a verified email to change it',
|
||||
detail='No email address to change',
|
||||
)
|
||||
|
||||
# Check if new email is the same as current
|
||||
@@ -1000,6 +1000,68 @@ async def request_email_change(
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
# Unverified email: replace directly and send verification to new address
|
||||
if not user.email_verified:
|
||||
old_email = user.email
|
||||
user.email = request.new_email.lower()
|
||||
user.email_verified = False
|
||||
|
||||
verification_token = generate_verification_token()
|
||||
verification_expires = get_verification_expires_at()
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = verification_expires
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
|
||||
cabinet_url = settings.CABINET_URL
|
||||
verification_url = f'{cabinet_url}/verify-email'
|
||||
lang = user.language or 'ru'
|
||||
full_url = f'{verification_url}?token={verification_token}'
|
||||
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
|
||||
override = await get_rendered_override(
|
||||
'email_verification',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'verification_url': full_url,
|
||||
'expire_hours': str(expire_hours),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
to_email=request.new_email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send verification email to {request.new_email} for user {user.id}: {e}')
|
||||
|
||||
logger.info(f'Unverified email replaced for user {user.id}: {old_email} -> {request.new_email}')
|
||||
|
||||
return EmailChangeResponse(
|
||||
message='Email replaced, verification sent to new address',
|
||||
new_email=request.new_email,
|
||||
expires_in_minutes=0,
|
||||
)
|
||||
|
||||
# Verified email: send code to new address for confirmation
|
||||
# Generate verification code
|
||||
code = generate_email_change_code()
|
||||
expires_at = get_email_change_expires_at()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -12,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, Transaction, User
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
@@ -23,6 +23,7 @@ from app.services.payment_verification_service import (
|
||||
method_display_name,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.utils.currency_converter import currency_converter
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.balance import (
|
||||
@@ -381,24 +382,49 @@ async def create_topup(
|
||||
)
|
||||
|
||||
elif request.payment_method == 'cryptobot':
|
||||
cryptobot_service = CryptoBotService()
|
||||
# Convert RUB to USDT (approximate)
|
||||
usdt_amount = amount_rubles / 100 # Approximate rate
|
||||
result = await cryptobot_service.create_invoice(
|
||||
amount=usdt_amount,
|
||||
asset='USDT',
|
||||
description=f'Balance top-up {amount_rubles:.2f} RUB',
|
||||
if not settings.is_cryptobot_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='CryptoBot payment method is unavailable',
|
||||
)
|
||||
|
||||
try:
|
||||
rate = await currency_converter.get_usd_to_rub_rate()
|
||||
except Exception:
|
||||
rate = 0.0
|
||||
if not rate or rate <= 0:
|
||||
rate = 95.0
|
||||
|
||||
try:
|
||||
amount_usd = float(
|
||||
(Decimal(request.amount_kopeks) / Decimal(100) / Decimal(str(rate))).quantize(
|
||||
Decimal('0.01'), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unable to convert amount to USD',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
),
|
||||
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
|
||||
)
|
||||
if result:
|
||||
# Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback
|
||||
payment_url = (
|
||||
result.get('web_app_invoice_url')
|
||||
result.get('bot_invoice_url')
|
||||
or result.get('mini_app_invoice_url')
|
||||
or result.get('bot_invoice_url')
|
||||
or result.get('pay_url')
|
||||
or result.get('web_app_invoice_url')
|
||||
)
|
||||
payment_id = str(result.get('invoice_id'))
|
||||
payment_id = result.get('invoice_id') or str(result.get('local_payment_id', 'pending'))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -619,7 +619,7 @@ async def get_traffic_packages(
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
return []
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
result = []
|
||||
|
||||
for pkg in packages:
|
||||
@@ -724,7 +724,7 @@ async def purchase_traffic(
|
||||
)
|
||||
|
||||
# Получаем цену из глобальных настроек
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
@@ -2407,7 +2407,7 @@ async def save_traffic_cart(
|
||||
detail='Докупка трафика отключена',
|
||||
)
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
@@ -3216,7 +3216,10 @@ async def update_countries(
|
||||
added_server_ids = await get_server_ids_by_uuids(db, added)
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, user.subscription, added_server_ids, added_server_prices)
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
try:
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления счётчика серверов: {e}')
|
||||
|
||||
# Update connected squads
|
||||
user.subscription.connected_squads = selected_countries
|
||||
|
||||
+14
-65
@@ -105,6 +105,11 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_AUTO_SYNC_TIMES: str = '03:00'
|
||||
CABINET_REMNA_SUB_CONFIG: str | None = None # UUID конфига страницы подписки из RemnaWave
|
||||
|
||||
# RemnaWave incoming webhooks (real-time event delivery from backend)
|
||||
REMNAWAVE_WEBHOOK_ENABLED: bool = False
|
||||
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
|
||||
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
@@ -162,11 +167,6 @@ class Settings(BaseSettings):
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: int | None = None
|
||||
|
||||
# Настройки модема
|
||||
MODEM_ENABLED: bool = False
|
||||
MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
|
||||
MODEM_PERIOD_DISCOUNTS: str = '' # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ''
|
||||
|
||||
@@ -395,6 +395,7 @@ class Settings(BaseSettings):
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
MULENPAY_IFRAME_EXPECTED_ORIGIN: str | None = None
|
||||
MULENPAY_WEBSITE_URL: str | None = None
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_DISPLAY_NAME: str = 'PAL24'
|
||||
@@ -1051,7 +1052,7 @@ class Settings(BaseSettings):
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
|
||||
return sanitized_username[:64]
|
||||
return sanitized_username[:36]
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -1089,6 +1090,13 @@ class Settings(BaseSettings):
|
||||
def get_remnawave_auto_sync_times(self) -> list[time]:
|
||||
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
|
||||
|
||||
def is_remnawave_webhook_enabled(self) -> bool:
|
||||
return (
|
||||
self.REMNAWAVE_WEBHOOK_ENABLED
|
||||
and bool(self.REMNAWAVE_WEBHOOK_SECRET)
|
||||
and len(self.REMNAWAVE_WEBHOOK_SECRET or '') >= 32
|
||||
)
|
||||
|
||||
def get_traffic_monitored_nodes(self) -> list[str]:
|
||||
"""Возвращает список UUID нод для мониторинга (пусто = все)"""
|
||||
if not self.TRAFFIC_MONITORED_NODES:
|
||||
@@ -1516,9 +1524,6 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> int | None:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_modem_enabled(self) -> bool:
|
||||
return bool(self.MODEM_ENABLED)
|
||||
|
||||
def is_tariffs_mode(self) -> bool:
|
||||
"""Проверяет, включен ли режим продаж 'Тарифы'."""
|
||||
return self.SALES_MODE == 'tariffs'
|
||||
@@ -1535,62 +1540,6 @@ class Settings(BaseSettings):
|
||||
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
|
||||
return max(0, self.TRIAL_TARIFF_ID)
|
||||
|
||||
def get_modem_price_per_month(self) -> int:
|
||||
try:
|
||||
value = int(self.MODEM_PRICE_PER_MONTH)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
'Некорректное значение MODEM_PRICE_PER_MONTH: %s',
|
||||
self.MODEM_PRICE_PER_MONTH,
|
||||
)
|
||||
return 10000
|
||||
return max(0, value)
|
||||
|
||||
def get_modem_period_discounts(self) -> dict[int, int]:
|
||||
"""Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
|
||||
try:
|
||||
config_str = (self.MODEM_PERIOD_DISCOUNTS or '').strip()
|
||||
if not config_str:
|
||||
return {}
|
||||
|
||||
discounts: dict[int, int] = {}
|
||||
for part in config_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
months_and_discount = part.split(':')
|
||||
if len(months_and_discount) != 2:
|
||||
continue
|
||||
|
||||
months_str, discount_str = months_and_discount
|
||||
try:
|
||||
months = int(months_str.strip())
|
||||
discount_percent = int(discount_str.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
discounts[months] = max(0, min(100, discount_percent))
|
||||
|
||||
return discounts
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_modem_period_discount(self, months: int) -> int:
|
||||
"""Возвращает процент скидки для указанного количества месяцев"""
|
||||
if months <= 0:
|
||||
return 0
|
||||
|
||||
discounts = self.get_modem_period_discounts()
|
||||
|
||||
# Ищем точное совпадение или ближайшее меньшее
|
||||
applicable_discount = 0
|
||||
for discount_months, discount_percent in sorted(discounts.items()):
|
||||
if months >= discount_months:
|
||||
applicable_discount = discount_percent
|
||||
|
||||
return applicable_discount
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
|
||||
# Если выключен - триал бесплатный, независимо от цены
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
Tariff,
|
||||
User,
|
||||
)
|
||||
|
||||
@@ -362,6 +363,25 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
cleaned_subscriptions += 1
|
||||
|
||||
# Clean up stale UUIDs from tariff allowed_squads
|
||||
cleaned_tariffs = 0
|
||||
tariffs_result = await db.execute(select(Tariff))
|
||||
for tariff in tariffs_result.scalars().all():
|
||||
current = list(tariff.allowed_squads or [])
|
||||
if not current:
|
||||
continue
|
||||
filtered = [u for u in current if u not in removed_uuids]
|
||||
if len(filtered) != len(current):
|
||||
tariff.allowed_squads = filtered
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
cleaned_tariffs += 1
|
||||
logger.info(
|
||||
'🧹 Тариф "%s" (ID: %s): удалены несуществующие сквады %s',
|
||||
tariff.name,
|
||||
tariff.id,
|
||||
[u for u in current if u in removed_uuids],
|
||||
)
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
|
||||
removed = len(removed_servers)
|
||||
|
||||
@@ -371,6 +391,12 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
cleaned_subscriptions,
|
||||
)
|
||||
|
||||
if cleaned_tariffs:
|
||||
logger.info(
|
||||
'🧹 Обновлены тарифы после удаления серверов: %s',
|
||||
cleaned_tariffs,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'🔄 Синхронизация завершена: +{created} ~{updated} -{removed}')
|
||||
@@ -740,14 +766,13 @@ async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) ->
|
||||
.values(current_users=ServerSquad.current_users + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка увеличения счетчика пользователей: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
|
||||
@@ -759,14 +784,13 @@ async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]
|
||||
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка уменьшения счетчика пользователей: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def get_server_ids_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[int]:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.notification import clear_notifications
|
||||
@@ -23,6 +24,16 @@ from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WEBHOOK_GUARD_SECONDS = 60
|
||||
|
||||
|
||||
def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
|
||||
"""Return True if subscription was updated by webhook within guard window."""
|
||||
if not subscription.last_webhook_update_at:
|
||||
return False
|
||||
elapsed = (datetime.now(UTC).replace(tzinfo=None) - subscription.last_webhook_update_at).total_seconds()
|
||||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||||
|
||||
|
||||
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
|
||||
result = await db.execute(
|
||||
@@ -347,9 +358,8 @@ async def extend_subscription(
|
||||
)
|
||||
|
||||
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
|
||||
is_tariff_change = (
|
||||
tariff_id is not None and subscription.tariff_id is not None and tariff_id != subscription.tariff_id
|
||||
)
|
||||
# Включает переход из классического режима (tariff_id=None) в тарифный
|
||||
is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id)
|
||||
|
||||
if is_tariff_change:
|
||||
logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}')
|
||||
@@ -430,17 +440,28 @@ async def extend_subscription(
|
||||
|
||||
if traffic_limit_gb is not None:
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
subscription.traffic_used_gb = 0.0
|
||||
# Сбрасываем все докупки трафика при смене тарифа
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
if is_tariff_change:
|
||||
# При СМЕНЕ тарифа сбрасываем все докупки трафика
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
|
||||
logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)')
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
logger.info(
|
||||
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)'
|
||||
)
|
||||
else:
|
||||
# При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик
|
||||
purchased = subscription.purchased_traffic_gb or 0
|
||||
subscription.traffic_limit_gb = traffic_limit_gb + purchased
|
||||
logger.info(
|
||||
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)'
|
||||
)
|
||||
elif settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
# В режиме тарифов сохраняем докупленный трафик при продлении
|
||||
@@ -605,6 +626,9 @@ async def decrement_subscription_server_counts(
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
# Save ID before any DB operations that might invalidate the ORM object
|
||||
sub_id = subscription.id
|
||||
|
||||
server_ids: set[int] = set()
|
||||
|
||||
if subscription_servers is not None:
|
||||
@@ -613,12 +637,12 @@ async def decrement_subscription_server_counts(
|
||||
server_ids.add(sub_server.server_squad_id)
|
||||
else:
|
||||
try:
|
||||
ids_from_links = await get_subscription_server_ids(db, subscription.id)
|
||||
ids_from_links = await get_subscription_server_ids(db, sub_id)
|
||||
server_ids.update(ids_from_links)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s',
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -632,7 +656,7 @@ async def decrement_subscription_server_counts(
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -642,12 +666,20 @@ async def decrement_subscription_server_counts(
|
||||
try:
|
||||
from app.database.crud.server_squad import remove_user_from_servers
|
||||
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
# Use savepoint so StaleDataError rollback doesn't affect the parent transaction
|
||||
async with db.begin_nested():
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
except StaleDataError:
|
||||
logger.warning(
|
||||
'⚠️ Подписка %s уже удалена (StaleDataError), пропускаем декремент серверов %s',
|
||||
sub_id,
|
||||
list(server_ids),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s',
|
||||
list(server_ids),
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ async def create_transaction(
|
||||
payment_method: PaymentMethod | None = None,
|
||||
external_id: str | None = None,
|
||||
is_completed: bool = True,
|
||||
created_at: datetime | None = None,
|
||||
) -> Transaction:
|
||||
transaction = Transaction(
|
||||
user_id=user_id,
|
||||
@@ -47,6 +48,7 @@ async def create_transaction(
|
||||
external_id=external_id,
|
||||
is_completed=is_completed,
|
||||
completed_at=datetime.utcnow() if is_completed else None,
|
||||
**({'created_at': created_at} if created_at else {}),
|
||||
)
|
||||
|
||||
db.add(transaction)
|
||||
|
||||
@@ -503,6 +503,10 @@ async def subtract_user_balance(
|
||||
logger.info(f' 💸 Сумма к списанию: {amount_kopeks} копеек')
|
||||
logger.info(f' 📝 Описание: {description}')
|
||||
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
locked_result = await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
log_context: dict[str, object] | None = None
|
||||
if consume_promo_offer:
|
||||
try:
|
||||
@@ -554,14 +558,13 @@ async def subtract_user_balance(
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
# create_trans commits the session, atomically persisting
|
||||
# both the balance change and the transaction record
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -570,6 +573,10 @@ async def subtract_user_balance(
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
|
||||
+21
-10
@@ -1137,6 +1137,8 @@ class Subscription(Base):
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
last_webhook_update_at = Column(DateTime, nullable=True)
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
# Тариф (для режима продаж "Тарифы")
|
||||
@@ -1157,17 +1159,25 @@ class Subscription(Base):
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date > current_time
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value
|
||||
and self.end_date is not None
|
||||
and self.end_date > current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Проверяет, истёк ли срок подписки"""
|
||||
return self.end_date <= datetime.utcnow()
|
||||
return self.end_date is not None and self.end_date <= datetime.utcnow()
|
||||
|
||||
@property
|
||||
def should_be_expired(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date <= current_time
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value
|
||||
and self.end_date is not None
|
||||
and self.end_date <= current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def actual_status(self) -> str:
|
||||
@@ -1180,12 +1190,12 @@ class Subscription(Base):
|
||||
return 'disabled'
|
||||
|
||||
if self.status == SubscriptionStatus.ACTIVE.value:
|
||||
if self.end_date <= current_time:
|
||||
if self.end_date is None or self.end_date <= current_time:
|
||||
return 'expired'
|
||||
return 'active'
|
||||
|
||||
if self.status == SubscriptionStatus.TRIAL.value:
|
||||
if self.end_date <= current_time:
|
||||
if self.end_date is None or self.end_date <= current_time:
|
||||
return 'expired'
|
||||
return 'trial'
|
||||
|
||||
@@ -1228,6 +1238,8 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def days_left(self) -> int:
|
||||
if self.end_date is None:
|
||||
return 0
|
||||
current_time = datetime.utcnow()
|
||||
if self.end_date <= current_time:
|
||||
return 0
|
||||
@@ -1253,11 +1265,10 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def traffic_used_percent(self) -> float:
|
||||
if self.traffic_limit_gb == 0:
|
||||
if not self.traffic_limit_gb:
|
||||
return 0.0
|
||||
if self.traffic_limit_gb > 0:
|
||||
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
|
||||
return 0.0
|
||||
used = self.traffic_used_gb or 0.0
|
||||
return min((used / self.traffic_limit_gb) * 100, 100.0)
|
||||
|
||||
def extend_subscription(self, days: int):
|
||||
if self.end_date > datetime.utcnow():
|
||||
@@ -2398,7 +2409,7 @@ class ButtonClickLog(Base):
|
||||
clicked_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# Дополнительная информация
|
||||
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
|
||||
button_type = Column(String(20), nullable=True, index=True) # builtin, callback, url, mini_app
|
||||
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
@@ -3613,6 +3613,33 @@ async def add_subscription_crypto_link_column() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_last_webhook_update_column() -> bool:
|
||||
column_exists = await check_column_exists('subscriptions', 'last_webhook_update_at')
|
||||
if column_exists:
|
||||
logger.info('ℹ️ Колонка last_webhook_update_at уже существует')
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at DATETIME'))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at TIMESTAMP'))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at DATETIME'))
|
||||
else:
|
||||
logger.error(f'Неподдерживаемый тип БД для добавления last_webhook_update_at: {db_type}')
|
||||
return False
|
||||
|
||||
logger.info('✅ Добавлена колонка last_webhook_update_at в таблицу subscriptions')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка добавления колонки last_webhook_update_at: {e}')
|
||||
return False
|
||||
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -7104,6 +7131,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с колонками OAuth провайдеров')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_WEBHOOK_UPDATE_AT ===')
|
||||
webhook_column_ready = await add_subscription_last_webhook_update_column()
|
||||
if webhook_column_ready:
|
||||
logger.info('✅ Колонка last_webhook_update_at готова')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с колонкой last_webhook_update_at')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions'))
|
||||
unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions'))
|
||||
|
||||
Vendored
+4
-3
@@ -400,8 +400,9 @@ class RemnaWaveAPI:
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f'API Error {response.status}: {error_message}')
|
||||
logger.error(f'Response: {response_text[:500]}')
|
||||
log = logger.warning if response.status in (502, 503, 504) else logger.error
|
||||
log('API Error %s: %s', response.status, error_message)
|
||||
log('Response: %s', response_text[:500])
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
|
||||
return response_data
|
||||
@@ -1284,5 +1285,5 @@ async def test_api_connection(api: RemnaWaveAPI) -> bool:
|
||||
await api.get_system_stats()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'API connection test failed: {e}')
|
||||
logger.warning('API connection test failed: %s', e)
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -11,6 +12,14 @@ from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import get_html_help_text, validate_html_tags
|
||||
|
||||
|
||||
def _safe_preview(html_text: str, limit: int = 500) -> str:
|
||||
"""Создаёт превью текста, безопасно обрезая HTML-теги."""
|
||||
plain = re.sub(r'<[^>]+>', '', html_text)
|
||||
if len(plain) <= limit:
|
||||
return plain
|
||||
return plain[:limit] + '...'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -79,7 +88,7 @@ async def start_edit_rules(callback: types.CallbackQuery, db_user: User, state:
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
|
||||
preview = _safe_preview(current_rules, 500)
|
||||
|
||||
text = (
|
||||
'✏️ <b>Редактирование правил</b>\n\n'
|
||||
@@ -139,7 +148,7 @@ async def process_rules_edit(message: types.Message, db_user: User, state: FSMCo
|
||||
if len(preview_text) > 4000:
|
||||
preview_text = (
|
||||
'📋 <b>Предварительный просмотр новых правил:</b>\n\n'
|
||||
f'{new_rules[:500]}...\n\n'
|
||||
f'{_safe_preview(new_rules, 500)}\n\n'
|
||||
f'⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n'
|
||||
f'Текст правил: {len(new_rules)} символов\n'
|
||||
f'Сохранить изменения?'
|
||||
|
||||
@@ -69,6 +69,10 @@ async def show_updates_menu(callback: types.CallbackQuery, db_user: User, db: As
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('📝 Сообщение не изменено в show_updates_menu')
|
||||
await callback.answer()
|
||||
return
|
||||
logger.error(f'Ошибка показа меню обновлений: {e}')
|
||||
await callback.answer('❌ Ошибка загрузки меню обновлений', show_alert=True)
|
||||
|
||||
@@ -118,6 +122,9 @@ async def check_updates(callback: types.CallbackQuery, db_user: User, db: AsyncS
|
||||
await callback.message.edit_text(message, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
except Exception as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('📝 Сообщение не изменено в check_updates')
|
||||
return
|
||||
logger.error(f'Ошибка проверки обновлений: {e}')
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>\n\n'
|
||||
@@ -142,7 +149,6 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As
|
||||
newer_releases = version_info['newer_releases']
|
||||
has_updates = version_info['has_updates']
|
||||
last_check = version_info['last_check']
|
||||
version_info['repo_url']
|
||||
|
||||
current_info = '📦 <b>ТЕКУЩАЯ ВЕРСИЯ</b>\n\n'
|
||||
|
||||
@@ -198,6 +204,9 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('📝 Сообщение не изменено в show_version_info')
|
||||
return
|
||||
logger.error(f'Ошибка получения информации о версиях: {e}')
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>ОШИБКА ЗАГРУЗКИ</b>\n\n'
|
||||
|
||||
@@ -901,16 +901,6 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
],
|
||||
]
|
||||
|
||||
if settings.is_modem_enabled():
|
||||
modem_status = '✅' if getattr(subscription, 'modem_enabled', False) else '❌'
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'📡 Модем ({modem_status})', callback_data=f'admin_user_modem_{user_id}'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Кнопки тарифов в режиме тарифов
|
||||
if settings.is_tariffs_mode():
|
||||
keyboard.append(
|
||||
@@ -3638,65 +3628,6 @@ async def set_user_devices_button(callback: types.CallbackQuery, db_user: User,
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_modem(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Переключение модема для пользователя в админке."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer('❌ Пользователь не найден', show_alert=True)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
if not subscription:
|
||||
await callback.answer('❌ У пользователя нет подписки', show_alert=True)
|
||||
return
|
||||
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
if modem_enabled:
|
||||
# Отключаем модем
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
action_text = 'отключен'
|
||||
else:
|
||||
# Включаем модем
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
action_text = 'подключен'
|
||||
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Обновляем в RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления RemnaWave при переключении модема: {e}')
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
modem_status = '✅ Подключен' if subscription.modem_enabled else '❌ Отключен'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📡 <b>Модем {action_text}</b>\n\nСтатус модема: {modem_status}\nЛимит устройств: {subscription.device_limit}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}'
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info(f'Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}')
|
||||
await callback.answer()
|
||||
|
||||
@@ -5578,8 +5509,6 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(set_user_devices_button, F.data.startswith('admin_user_devices_set_'))
|
||||
|
||||
dp.callback_query.register(toggle_user_modem, F.data.startswith('admin_user_modem_'))
|
||||
|
||||
# Смена тарифа пользователя
|
||||
dp.callback_query.register(show_admin_tariff_change, F.data.startswith('admin_sub_change_tariff_'))
|
||||
|
||||
|
||||
+20
-1
@@ -25,6 +25,24 @@ async def handle_delete_ban_notification(
|
||||
await callback.answer('Не удалось удалить', show_alert=False)
|
||||
|
||||
|
||||
async def handle_webhook_notification_close(
|
||||
callback: types.CallbackQuery,
|
||||
):
|
||||
"""Удаляет webhook-уведомление при нажатии кнопки Закрыть."""
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception as e:
|
||||
logger.warning('Не удалось удалить webhook-уведомление: %s', e)
|
||||
try:
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_unknown_callback(callback: types.CallbackQuery, db_user: User):
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
@@ -86,8 +104,9 @@ async def show_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSess
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
# Удаление уведомлений о банах
|
||||
# Удаление уведомлений
|
||||
dp.callback_query.register(handle_delete_ban_notification, F.data == 'ban_notify:delete')
|
||||
dp.callback_query.register(handle_webhook_notification_close, F.data == 'webhook:close')
|
||||
|
||||
dp.callback_query.register(show_rules, F.data == 'menu_rules')
|
||||
|
||||
|
||||
@@ -70,24 +70,15 @@ async def start_simple_subscription_purchase(
|
||||
# (независимо от того, включён ли выбор устройств)
|
||||
if current_subscription:
|
||||
current_device_limit = current_subscription.device_limit or device_limit
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно
|
||||
if getattr(current_subscription, 'modem_enabled', False):
|
||||
current_device_limit = max(1, current_device_limit - 1)
|
||||
# Используем максимум из текущего и дефолтного
|
||||
device_limit = max(device_limit, current_device_limit)
|
||||
|
||||
# Проверяем, включён ли модем у текущей подписки
|
||||
modem_enabled = False
|
||||
if current_subscription:
|
||||
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
'period_days': settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
'squad_uuid': settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
|
||||
'modem_enabled': modem_enabled,
|
||||
}
|
||||
|
||||
# Сохраняем параметры в состояние
|
||||
@@ -113,13 +104,12 @@ async def start_simple_subscription_purchase(
|
||||
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
|
||||
|
||||
logger.warning(
|
||||
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | total=%s | squads=%s',
|
||||
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s',
|
||||
db_user.id,
|
||||
period_days,
|
||||
price_breakdown.get('base_price', 0),
|
||||
price_breakdown.get('traffic_price', 0),
|
||||
price_breakdown.get('devices_price', 0),
|
||||
price_breakdown.get('modem_price', 0),
|
||||
price_breakdown.get('servers_price', 0),
|
||||
price_breakdown.get('total_discount', 0),
|
||||
price_kopeks,
|
||||
|
||||
+34
-13
@@ -931,14 +931,11 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' not in str(e):
|
||||
logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}')
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
except:
|
||||
pass
|
||||
logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}')
|
||||
|
||||
logger.info(f'✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}')
|
||||
|
||||
@@ -1228,6 +1225,20 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
)
|
||||
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
except TelegramBadRequest as e:
|
||||
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
|
||||
logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}')
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
parse_mode=None,
|
||||
)
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
except Exception as fallback_err:
|
||||
logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}')
|
||||
else:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
else:
|
||||
@@ -1507,6 +1518,20 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
)
|
||||
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
except TelegramBadRequest as e:
|
||||
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
|
||||
logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}')
|
||||
try:
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=None,
|
||||
)
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
except Exception as fallback_err:
|
||||
logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}')
|
||||
else:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
else:
|
||||
@@ -1735,6 +1760,8 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
async def required_sub_channel_check(
|
||||
query: types.CallbackQuery, bot: Bot, state: FSMContext, db: AsyncSession, db_user=None
|
||||
):
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -1880,8 +1907,6 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
|
||||
@@ -1974,8 +1999,6 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
|
||||
@@ -2028,8 +2051,6 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
|
||||
@@ -64,13 +64,6 @@ from .links import (
|
||||
handle_connect_subscription,
|
||||
handle_open_subscription_link,
|
||||
)
|
||||
from .modem import (
|
||||
handle_modem_confirm,
|
||||
handle_modem_disable,
|
||||
handle_modem_enable,
|
||||
handle_modem_menu,
|
||||
register_modem_handlers,
|
||||
)
|
||||
from .notifications import (
|
||||
send_extension_notification,
|
||||
send_purchase_notification,
|
||||
@@ -172,10 +165,6 @@ __all__ = [
|
||||
'handle_happ_download_platform_choice',
|
||||
'handle_happ_download_request',
|
||||
'handle_manage_country',
|
||||
'handle_modem_confirm',
|
||||
'handle_modem_disable',
|
||||
'handle_modem_enable',
|
||||
'handle_modem_menu',
|
||||
'handle_no_traffic_packages',
|
||||
'handle_open_subscription_link',
|
||||
'handle_promo_offer_close',
|
||||
@@ -190,7 +179,6 @@ __all__ = [
|
||||
'load_app_config',
|
||||
'refresh_traffic_config',
|
||||
'register_handlers',
|
||||
'register_modem_handlers',
|
||||
'resume_subscription_checkout',
|
||||
'return_to_saved_cart',
|
||||
'save_cart_and_redirect_to_topup',
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
"""
|
||||
Хендлеры для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
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, get_insufficient_balance_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.modem_service import (
|
||||
ModemError,
|
||||
get_modem_service,
|
||||
)
|
||||
from app.utils.decorators import error_handler, modem_available
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_modem_keyboard(language: str, modem_enabled: bool):
|
||||
"""Клавиатура управления модемом."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if modem_enabled:
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('MODEM_DISABLE_BUTTON', 'Отключить модем'), callback_data='modem_disable'
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('MODEM_ENABLE_BUTTON', 'Подключить модем'), callback_data='modem_enable'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_settings')])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_modem_confirm_keyboard(language: str):
|
||||
"""Клавиатура подтверждения подключения модема."""
|
||||
texts = get_texts(language)
|
||||
return types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('MODEM_CONFIRM_BUTTON', 'Подтвердить подключение'), callback_data='modem_confirm'
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.CANCEL, callback_data='subscription_modem')],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available()
|
||||
async def handle_modem_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Показывает меню управления модемом."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
modem_enabled = service.get_modem_enabled(subscription)
|
||||
modem_price = settings.get_modem_price_per_month()
|
||||
|
||||
if modem_enabled:
|
||||
status_text = texts.t('MODEM_STATUS_ENABLED', 'Подключен')
|
||||
info_text = texts.t(
|
||||
'MODEM_INFO_ENABLED',
|
||||
(
|
||||
'<b>Модем</b>\n\n'
|
||||
'Статус: {status}\n\n'
|
||||
'Модем подключен к вашей подписке.\n'
|
||||
'Ежемесячная плата: {price}\n\n'
|
||||
'При отключении модема возврат средств не производится.'
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
else:
|
||||
status_text = texts.t('MODEM_STATUS_DISABLED', 'Не подключен')
|
||||
info_text = texts.t(
|
||||
'MODEM_INFO_DISABLED',
|
||||
(
|
||||
'<b>Модем</b>\n\n'
|
||||
'Статус: {status}\n\n'
|
||||
'Подключите модем к вашей подписке.\n'
|
||||
'Ежемесячная плата: {price}\n\n'
|
||||
'При подключении модема будет добавлено дополнительное устройство.'
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
info_text, reply_markup=get_modem_keyboard(db_user.language, modem_enabled), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_enable(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Обработчик подключения модема - показывает информацию о цене."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
price_info = service.calculate_price(subscription)
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
has_funds, missing_kopeks = service.check_balance(db_user, price_info.final_price)
|
||||
|
||||
if not has_funds:
|
||||
if price_info.has_discount:
|
||||
required_text = (
|
||||
f'{texts.format_price(price_info.final_price)} '
|
||||
f'(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)'
|
||||
)
|
||||
else:
|
||||
required_text = f'{texts.format_price(price_info.final_price)} (за {price_info.charged_months} мес)'
|
||||
|
||||
message_text = texts.t(
|
||||
'MODEM_INSUFFICIENT_FUNDS',
|
||||
(
|
||||
'<b>Недостаточно средств</b>\n\n'
|
||||
'Стоимость подключения модема: {required}\n'
|
||||
'На балансе: {balance}\n'
|
||||
'Не хватает: {missing}\n\n'
|
||||
'Выберите способ пополнения.'
|
||||
),
|
||||
).format(
|
||||
required=required_text,
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
warning_level = service.get_period_warning_level(price_info.remaining_days)
|
||||
|
||||
if warning_level == 'critical':
|
||||
warning_text = texts.t(
|
||||
'MODEM_SHORT_PERIOD_WARNING',
|
||||
'\n<b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\n'
|
||||
'После продления подписки модем нужно будет оплатить заново!',
|
||||
).format(days=price_info.remaining_days)
|
||||
elif warning_level == 'info':
|
||||
warning_text = texts.t(
|
||||
'MODEM_PERIOD_NOTE',
|
||||
'\nДо окончания подписки: <b>{days} дн.</b>\nПосле продления модем нужно будет оплатить заново.',
|
||||
).format(days=price_info.remaining_days)
|
||||
else:
|
||||
warning_text = ''
|
||||
|
||||
if price_info.has_discount:
|
||||
price_text = texts.t(
|
||||
'MODEM_PRICE_WITH_DISCOUNT',
|
||||
'Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n'
|
||||
'Скидка {discount}%: -{discount_amount}',
|
||||
).format(
|
||||
base_price=texts.format_price(price_info.base_price),
|
||||
final_price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
discount=price_info.discount_percent,
|
||||
discount_amount=texts.format_price(price_info.discount_amount),
|
||||
)
|
||||
else:
|
||||
price_text = texts.t('MODEM_PRICE_NO_DISCOUNT', 'Стоимость: {price} (за {months} мес)').format(
|
||||
price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
)
|
||||
|
||||
confirm_text = texts.t(
|
||||
'MODEM_CONFIRM_ENABLE_BASE',
|
||||
(
|
||||
'<b>Подтверждение подключения модема</b>\n\n'
|
||||
'{price_text}\n\n'
|
||||
'При подключении модема:\n'
|
||||
'К подписке добавится дополнительное устройство\n'
|
||||
'Ежемесячная плата увеличится на {monthly_price}\n\n'
|
||||
'Подтвердить подключение?'
|
||||
),
|
||||
).format(
|
||||
price_text=price_text,
|
||||
monthly_price=texts.format_price(modem_price_per_month),
|
||||
)
|
||||
|
||||
end_date_str = price_info.end_date.strftime('%d.%m.%Y')
|
||||
period_info = texts.t('MODEM_PERIOD_INFO', '\nМодем действует до: <b>{end_date}</b> ({days} дн.)').format(
|
||||
end_date=end_date_str, days=price_info.remaining_days
|
||||
)
|
||||
|
||||
confirm_text += period_info + warning_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text, reply_markup=get_modem_confirm_keyboard(db_user.language), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Подтверждение и активация модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.enable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
error_messages = {
|
||||
ModemError.INSUFFICIENT_FUNDS: texts.t('MODEM_INSUFFICIENT_FUNDS_SHORT', 'Недостаточно средств на балансе'),
|
||||
ModemError.CHARGE_ERROR: texts.t('PAYMENT_CHARGE_ERROR', 'Ошибка списания средств'),
|
||||
ModemError.UPDATE_ERROR: texts.ERROR,
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result.error, texts.ERROR)
|
||||
|
||||
if result.error == ModemError.INSUFFICIENT_FUNDS:
|
||||
await callback.message.edit_text(
|
||||
error_text, reply_markup=get_back_keyboard(db_user.language, 'modem_enable'), parse_mode='HTML'
|
||||
)
|
||||
else:
|
||||
await callback.answer(error_text, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'modem', False, True, result.charged_amount
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о подключении модема: {e}')
|
||||
|
||||
success_text = texts.t(
|
||||
'MODEM_ENABLED_SUCCESS',
|
||||
('<b>Модем успешно подключен!</b>\n\nМодем активирован\nДобавлено устройство для модема\n'),
|
||||
)
|
||||
if result.charged_amount > 0:
|
||||
success_text += texts.t(
|
||||
'MODEM_CHARGED',
|
||||
'Списано: {amount}',
|
||||
).format(amount=texts.format_price(result.charged_amount))
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_disable=True)
|
||||
async def handle_modem_disable(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Отключение модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.disable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
await callback.answer(texts.ERROR, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'modem', True, False, 0
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления об отключении модема: {e}')
|
||||
|
||||
success_text = texts.t(
|
||||
'MODEM_DISABLED_SUCCESS',
|
||||
('<b>Модем отключен</b>\n\nМодем деактивирован\nВозврат средств не производится'),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_modem_handlers(dp: Dispatcher):
|
||||
"""Регистрация обработчиков модема."""
|
||||
dp.callback_query.register(handle_modem_menu, F.data == 'subscription_modem')
|
||||
|
||||
dp.callback_query.register(handle_modem_enable, F.data == 'modem_enable')
|
||||
|
||||
dp.callback_query.register(handle_modem_confirm, F.data == 'modem_confirm')
|
||||
|
||||
dp.callback_query.register(handle_modem_disable, F.data == 'modem_disable')
|
||||
@@ -404,15 +404,16 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
status_text = '⌛ Истекла'
|
||||
type_text = 'Платная подписка'
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
traffic_limit = subscription.traffic_limit_gb or 0
|
||||
if traffic_limit == 0:
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_text = '∞ Безлимитный'
|
||||
else:
|
||||
traffic_text = '∞ Безлимитный'
|
||||
elif settings.is_traffic_fixed():
|
||||
traffic_text = f'{subscription.traffic_limit_gb} ГБ'
|
||||
traffic_text = f'{traffic_limit} ГБ'
|
||||
else:
|
||||
traffic_text = f'{subscription.traffic_limit_gb} ГБ'
|
||||
traffic_text = f'{traffic_limit} ГБ'
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
@@ -444,7 +445,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
info_text += f'\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}'
|
||||
|
||||
# Отображаем докупленный трафик
|
||||
if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов
|
||||
if (subscription.traffic_limit_gb or 0) > 0: # Только для лимитированных тарифов
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select as sql_select
|
||||
|
||||
@@ -426,14 +426,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
'',
|
||||
)
|
||||
|
||||
# Формируем отображение лимита устройств с учётом модема
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
# Показываем лимит без модема + модем
|
||||
visible_device_limit = (subscription.device_limit or 1) - 1
|
||||
device_limit_display = f'{visible_device_limit} + модем'
|
||||
else:
|
||||
device_limit_display = str(subscription.device_limit)
|
||||
device_limit_display = str(subscription.device_limit)
|
||||
|
||||
message = message_template.format(
|
||||
full_name=db_user.full_name,
|
||||
@@ -1603,11 +1596,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_base = devices_price_per_month * months_in_period
|
||||
@@ -1814,11 +1802,6 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
@@ -2420,26 +2403,31 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
promo_offer_discount_percent = 0
|
||||
|
||||
# Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене
|
||||
# Допускаем небольшое расхождение из-за округления (до 5%)
|
||||
price_difference = abs(final_price - cached_total_price)
|
||||
max_allowed_difference = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
|
||||
|
||||
if price_difference > max_allowed_difference:
|
||||
# Слишком большое расхождение - блокируем покупку
|
||||
logger.error(
|
||||
f'Критическое расхождение цены для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, '
|
||||
f'разница={price_difference / 100}₽ (>{max_allowed_difference / 100}₽). '
|
||||
f'Покупка заблокирована.'
|
||||
)
|
||||
await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True)
|
||||
return
|
||||
if price_difference > 100: # допуск 1₽
|
||||
# Небольшое расхождение - логируем предупреждение но продолжаем
|
||||
logger.warning(
|
||||
f'Расхождение цены для пользователя {db_user.telegram_id}: '
|
||||
# Блокируем только если цена ВЫРОСЛА (пользователь переплатит).
|
||||
# Если цена снизилась (промо-скидка активировалась) — разрешаем покупку по новой цене.
|
||||
price_difference = final_price - cached_total_price
|
||||
if price_difference > 0:
|
||||
max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
|
||||
if price_difference > max_allowed_increase:
|
||||
logger.error(
|
||||
f'Цена выросла для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, '
|
||||
f'разница=+{price_difference / 100}₽ (>{max_allowed_increase / 100}₽). '
|
||||
f'Покупка заблокирована.'
|
||||
)
|
||||
await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True)
|
||||
return
|
||||
if price_difference > 100: # допуск 1₽
|
||||
logger.warning(
|
||||
f'Небольшой рост цены для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. '
|
||||
f'Используем пересчитанную цену.'
|
||||
)
|
||||
elif price_difference < -100: # цена снизилась более чем на 1₽
|
||||
logger.info(
|
||||
f'Цена снизилась для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. '
|
||||
f'Используем пересчитанную цену.'
|
||||
f'Применяем новую цену.'
|
||||
)
|
||||
|
||||
# Используем пересчитанную цену
|
||||
@@ -3060,13 +3048,7 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
|
||||
'',
|
||||
)
|
||||
|
||||
# Формируем отображение лимита устройств с учётом модема
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
visible_device_limit = (subscription.device_limit or 1) - 1
|
||||
devices_limit_display = f'{visible_device_limit} + модем'
|
||||
else:
|
||||
devices_limit_display = str(subscription.device_limit)
|
||||
devices_limit_display = str(subscription.device_limit)
|
||||
|
||||
settings_text = settings_template.format(
|
||||
countries_count=len(subscription.connected_squads),
|
||||
@@ -4113,11 +4095,6 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_device_connection_help, F.data == 'device_connection_help')
|
||||
|
||||
# Регистрируем обработчики модема
|
||||
from .modem import register_modem_handlers
|
||||
|
||||
register_modem_handlers(dp)
|
||||
|
||||
# Регистрируем обработчики покупки по тарифам
|
||||
from .tariff_purchase import register_tariff_purchase_handlers
|
||||
|
||||
@@ -4152,10 +4129,6 @@ async def handle_simple_subscription_purchase(
|
||||
if current_subscription and current_subscription.is_active:
|
||||
# При продлении используем текущие устройства подписки, а не дефолтные
|
||||
extend_device_limit = current_subscription.device_limit or simple_device_limit
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно
|
||||
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
|
||||
if modem_enabled:
|
||||
extend_device_limit = max(1, extend_device_limit - 1)
|
||||
# Используем максимум из текущего и дефолтного
|
||||
extend_device_limit = max(simple_device_limit, extend_device_limit)
|
||||
|
||||
@@ -4169,7 +4142,6 @@ async def handle_simple_subscription_purchase(
|
||||
device_limit=extend_device_limit,
|
||||
traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
|
||||
modem_enabled=modem_enabled,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -4289,7 +4261,6 @@ async def _extend_existing_subscription(
|
||||
device_limit: int,
|
||||
traffic_limit_gb: int,
|
||||
squad_uuid: str,
|
||||
modem_enabled: bool = False,
|
||||
):
|
||||
"""Продлевает существующую подписку."""
|
||||
from datetime import datetime, timedelta
|
||||
@@ -4307,7 +4278,6 @@ async def _extend_existing_subscription(
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'squad_uuid': squad_uuid,
|
||||
'modem_enabled': modem_enabled,
|
||||
}
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
@@ -4316,17 +4286,15 @@ async def _extend_existing_subscription(
|
||||
resolved_squad_uuid=squad_uuid,
|
||||
)
|
||||
logger.warning(
|
||||
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | device_limit=%s | modem_enabled=%s',
|
||||
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | device_limit=%s',
|
||||
db_user.id,
|
||||
price_kopeks,
|
||||
price_breakdown.get('base_price', 0),
|
||||
price_breakdown.get('traffic_price', 0),
|
||||
price_breakdown.get('devices_price', 0),
|
||||
price_breakdown.get('modem_price', 0),
|
||||
price_breakdown.get('servers_price', 0),
|
||||
price_breakdown.get('total_discount', 0),
|
||||
device_limit,
|
||||
modem_enabled,
|
||||
)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
|
||||
@@ -47,8 +47,6 @@ async def get_main_menu_keyboard_async(
|
||||
Иначе делегирует в синхронную версию.
|
||||
"""
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.menu_layout_service import MenuContext, MenuLayoutService
|
||||
|
||||
# Получаем данные для плейсхолдеров
|
||||
@@ -2622,11 +2620,6 @@ def get_updated_subscription_settings_keyboard(
|
||||
]
|
||||
)
|
||||
|
||||
if settings.is_modem_enabled() and not has_tariff:
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text=texts.t('MODEM_BUTTON', '📡 Модем'), callback_data='subscription_modem')]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
|
||||
@@ -1698,5 +1698,24 @@
|
||||
"RESUME_DAILY_BUTTON": "▶️ Resume subscription",
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!"
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew to restore VPN access.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Subscription disabled</b>\n\nYour subscription has been disabled by the administrator.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Subscription activated</b>\n\nYour subscription is active again. Enjoy!",
|
||||
"WEBHOOK_SUB_LIMITED": "⚠️ <b>Traffic limit reached</b>\n\nYou have used all available traffic. Purchase additional traffic or wait for a reset.",
|
||||
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>Traffic reset</b>\n\nYour traffic has been reset. Full limit is available again.",
|
||||
"WEBHOOK_SUB_DELETED": "🗑️ <b>Subscription deleted</b>\n\nYour subscription was deleted from the panel.",
|
||||
"WEBHOOK_SUB_REVOKED": "🔑 <b>Subscription key updated</b>\n\nYour connection credentials have been updated. Use the new link to connect.",
|
||||
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>Subscription expires in 3 days</b>\n\nDon't forget to renew your subscription to keep VPN access.",
|
||||
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>Subscription expires in 2 days</b>\n\nRenew your subscription in advance to maintain VPN access.",
|
||||
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>Subscription expires tomorrow!</b>\n\nLess than 24 hours left. Renew your subscription now.",
|
||||
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>Subscription expired yesterday</b>\n\nYour subscription has ended. Renew now to restore VPN access.",
|
||||
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>First connection!</b>\n\nYou connected to the VPN for the first time. Welcome!",
|
||||
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>{percent}% traffic used</b>\n\nYou have used a significant portion of your traffic. Monitor your usage.",
|
||||
"WEBHOOK_RENEW_BUTTON": "🔄 Renew subscription",
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Not connected yet</b>\n\nYour subscription is active but no VPN connection has been made. Connect to start using the service.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription: <code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
|
||||
}
|
||||
|
||||
@@ -1436,7 +1436,7 @@
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>اتصال از طریق Happ</b>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 دکمه زیر بزنید تا راهنمای تنظیم VPN روی دستگاه را ببینید",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>لینک وارد کردن در اپلیکیشن VPN:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_INFO": "ℹ️ اطلاعات اشتراک",
|
||||
"SUBSCRIPTION_INFO": "\n📱 <b>اطلاعات اشتراک</b>\n\n📊 <b>وضعیت:</b> {status}\n🎭 <b>نوع:</b> {type}\n📅 <b>اعتبار تا:</b> {end_date}\n⏰ <b>روزهای باقیمانده:</b> {days_left}\n\n📈 <b>ترافیک:</b> {traffic_used} / {traffic_limit}\n🌍 <b>سرورها:</b> {countries_count} کشور\n📱 <b>دستگاهها:</b> {devices_used} / {devices_limit}\n\n💳 <b>پرداخت خودکار:</b> {autopay_status}\n",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nلینک در حال ایجاد است. چند ثانیه بعد به «اشتراک من» بروید.",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ لینک اشتراک از دکمههای زیر یا بخش «اشتراک من» در دسترس است.",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 لینک را در اپلیکیشن VPN وارد کنید",
|
||||
@@ -1717,5 +1717,23 @@
|
||||
"RESUME_DAILY_BUTTON": "▶️ ازسرگیری اشتراک",
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>توجه!</b> {days} روز اشتراک باقی مانده.\nبا تغییر به تعرفه روزانه از دست میروند!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ اشتراک متوقف شد",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ اشتراک از سر گرفته شد!"
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ اشتراک از سر گرفته شد!",
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>اشتراک منقضی شد</b>\n\nاشتراک شما به پایان رسیده است. برای بازیابی دسترسی VPN تمدید کنید.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>اشتراک غیرفعال شد</b>\n\nاشتراک شما توسط مدیر غیرفعال شده است.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>اشتراک فعال شد</b>\n\nاشتراک شما دوباره فعال است. از استفاده لذت ببرید!",
|
||||
"WEBHOOK_SUB_LIMITED": "⚠️ <b>محدودیت ترافیک</b>\n\nشما تمام ترافیک موجود را مصرف کردهاید. ترافیک اضافی بخرید یا منتظر بازنشانی باشید.",
|
||||
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>ترافیک بازنشانی شد</b>\n\nترافیک شما بازنشانی شد. محدودیت کامل دوباره در دسترس است.",
|
||||
"WEBHOOK_SUB_DELETED": "🗑️ <b>اشتراک حذف شد</b>\n\nاشتراک شما از پنل حذف شده است.",
|
||||
"WEBHOOK_SUB_REVOKED": "🔑 <b>کلید اشتراک بهروز شد</b>\n\nاطلاعات اتصال شما بهروز شده است. از لینک جدید برای اتصال استفاده کنید.",
|
||||
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>اشتراک تا ۳ روز دیگر منقضی میشود</b>\n\nفراموش نکنید اشتراک خود را تمدید کنید.",
|
||||
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>اشتراک تا ۲ روز دیگر منقضی میشود</b>\n\nاشتراک خود را زودتر تمدید کنید تا دسترسی VPN حفظ شود.",
|
||||
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>اشتراک فردا منقضی میشود!</b>\n\nکمتر از ۲۴ ساعت باقی مانده. همین الان تمدید کنید.",
|
||||
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>اشتراک دیروز منقضی شد</b>\n\nاشتراک شما به پایان رسیده. همین الان تمدید کنید تا دسترسی VPN بازگردد.",
|
||||
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>اولین اتصال!</b>\n\nشما برای اولین بار به VPN متصل شدید. خوش آمدید!",
|
||||
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>{percent}% ترافیک مصرف شد</b>\n\nبخش قابل توجهی از ترافیک خود را مصرف کردهاید. مصرف خود را زیر نظر داشته باشید.",
|
||||
"WEBHOOK_RENEW_BUTTON": "🔄 تمدید اشتراک",
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>هنوز متصل نشدهاید</b>\n\nاشتراک شما فعال است اما هنوز اتصال VPN برقرار نشده. برای شروع استفاده متصل شوید.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>دستگاه جدید</b>\n\nدستگاه جدیدی به اشتراک شما اضافه شد: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>دستگاه حذف شد</b>\n\nدستگاهی از اشتراک شما حذف شد: <code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن"
|
||||
}
|
||||
@@ -1719,5 +1719,24 @@
|
||||
"RESUME_DAILY_BUTTON": "▶️ Возобновить подписку",
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!"
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка отключена</b>\n\nВаша подписка была отключена администратором.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Подписка активирована</b>\n\nВаша подписка снова активна. Приятного использования!",
|
||||
"WEBHOOK_SUB_LIMITED": "⚠️ <b>Достигнут лимит трафика</b>\n\nВы исчерпали весь доступный трафик. Докупите трафик или дождитесь сброса.",
|
||||
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>Трафик сброшен</b>\n\nВаш трафик обнулён. Лимит снова доступен в полном объёме.",
|
||||
"WEBHOOK_SUB_DELETED": "🗑️ <b>Подписка удалена</b>\n\nВаша подписка была удалена из панели.",
|
||||
"WEBHOOK_SUB_REVOKED": "🔑 <b>Ключ подписки обновлён</b>\n\nВаши данные подключения были обновлены. Используйте новую ссылку для подключения.",
|
||||
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>Подписка истекает через 3 дня</b>\n\nНе забудьте продлить подписку, чтобы не потерять доступ.",
|
||||
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>Подписка истекает через 2 дня</b>\n\nПродлите подписку заранее, чтобы сохранить доступ к VPN.",
|
||||
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>Подписка истекает завтра!</b>\n\nОсталось менее 24 часов. Продлите подписку прямо сейчас.",
|
||||
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>Подписка истекла вчера</b>\n\nВаша подписка завершена. Продлите сейчас, чтобы вернуть доступ к VPN.",
|
||||
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>Первое подключение!</b>\n\nВы впервые подключились к VPN. Добро пожаловать!",
|
||||
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>Использован {percent}% трафика</b>\n\nВы использовали значительную часть трафика. Следите за расходом.",
|
||||
"WEBHOOK_RENEW_BUTTON": "🔄 Продлить подписку",
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Вы ещё не подключились</b>\n\nВаша подписка активна, но VPN-соединение не было установлено. Подключитесь, чтобы начать пользоваться.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новое устройство</b>\n\nК вашей подписке подключено новое устройство: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Устройство удалено</b>\n\nУстройство отключено от подписки: <code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть"
|
||||
}
|
||||
|
||||
@@ -1586,5 +1586,23 @@
|
||||
"MODEM_PERIOD_NOTE": "\nℹ️ До закінчення підписки: <b>{days} дн.</b>\nПісля продовження модем потрібно буде оплатити знову.",
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?"
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Підписка закінчилась</b>\n\nВаша підписка завершена. Продовжте підписку, щоб відновити доступ до VPN.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Підписку вимкнено</b>\n\nВашу підписку було вимкнено адміністратором.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Підписку активовано</b>\n\nВаша підписка знову активна. Приємного використання!",
|
||||
"WEBHOOK_SUB_LIMITED": "⚠️ <b>Досягнуто ліміт трафіку</b>\n\nВи вичерпали весь доступний трафік. Докупіть трафік або дочекайтесь скидання.",
|
||||
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>Трафік скинуто</b>\n\nВаш трафік обнулено. Ліміт знову доступний у повному обсязі.",
|
||||
"WEBHOOK_SUB_DELETED": "🗑️ <b>Підписку видалено</b>\n\nВашу підписку було видалено з панелі.",
|
||||
"WEBHOOK_SUB_REVOKED": "🔑 <b>Ключ підписки оновлено</b>\n\nВаші дані підключення були оновлені. Використовуйте нове посилання для підключення.",
|
||||
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>Підписка закінчується через 3 дні</b>\n\nНе забудьте продовжити підписку, щоб не втратити доступ.",
|
||||
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>Підписка закінчується через 2 дні</b>\n\nПродовжте підписку заздалегідь, щоб зберегти доступ до VPN.",
|
||||
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>Підписка закінчується завтра!</b>\n\nЗалишилось менше 24 годин. Продовжте підписку прямо зараз.",
|
||||
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>Підписка закінчилась вчора</b>\n\nВаша підписка завершена. Продовжте зараз, щоб повернути доступ до VPN.",
|
||||
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>Перше підключення!</b>\n\nВи вперше підключились до VPN. Ласкаво просимо!",
|
||||
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>Використано {percent}% трафіку</b>\n\nВи використали значну частину трафіку. Слідкуйте за витратою.",
|
||||
"WEBHOOK_RENEW_BUTTON": "🔄 Продовжити підписку",
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Ви ще не підключились</b>\n\nВаша підписка активна, але VPN-з'єднання не було встановлено. Підключіться, щоб почати користуватися.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новий пристрій</b>\n\nДо вашої підписки підключено новий пристрій: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Пристрій видалено</b>\n\nПристрій відключено від підписки: <code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити"
|
||||
}
|
||||
|
||||
@@ -1919,5 +1919,23 @@
|
||||
"MODEM_PERIOD_NOTE":"\nℹ️ 距离订阅结束:<b>{days}天</b>\n续订后需重新支付调制解调器费用。",
|
||||
"MODEM_PRICE_WITH_DISCOUNT":"费用:<s>{base_price}</s> <b>{final_price}</b>({months}个月)\n🎁 折扣{discount}%:-{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT":"费用:{price}({months}个月)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?"
|
||||
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
|
||||
"WEBHOOK_SUB_EXPIRED":"❌ <b>订阅已过期</b>\n\n您的订阅已结束。请续订以恢复VPN访问。",
|
||||
"WEBHOOK_SUB_DISABLED":"🚫 <b>订阅已禁用</b>\n\n您的订阅已被管理员禁用。",
|
||||
"WEBHOOK_SUB_ENABLED":"✅ <b>订阅已激活</b>\n\n您的订阅已重新激活。祝使用愉快!",
|
||||
"WEBHOOK_SUB_LIMITED":"⚠️ <b>已达到流量限制</b>\n\n您已用完所有可用流量。请购买额外流量或等待重置。",
|
||||
"WEBHOOK_SUB_TRAFFIC_RESET":"🔄 <b>流量已重置</b>\n\n您的流量已重置。完整限额再次可用。",
|
||||
"WEBHOOK_SUB_DELETED":"🗑️ <b>订阅已删除</b>\n\n您的订阅已从面板中删除。",
|
||||
"WEBHOOK_SUB_REVOKED":"🔑 <b>订阅密钥已更新</b>\n\n您的连接凭证已更新。请使用新链接进行连接。",
|
||||
"WEBHOOK_SUB_EXPIRES_72H":"⏳ <b>订阅将在3天后到期</b>\n\n请记得续订以保持VPN访问。",
|
||||
"WEBHOOK_SUB_EXPIRES_48H":"⏳ <b>订阅将在2天后到期</b>\n\n请提前续订以保持VPN访问。",
|
||||
"WEBHOOK_SUB_EXPIRES_24H":"🔴 <b>订阅明天到期!</b>\n\n不到24小时。请立即续订。",
|
||||
"WEBHOOK_SUB_EXPIRED_24H_AGO":"💤 <b>订阅昨天已过期</b>\n\n您的订阅已结束。请立即续订以恢复VPN访问。",
|
||||
"WEBHOOK_SUB_FIRST_CONNECTED":"🎉 <b>首次连接!</b>\n\n您首次连接了VPN。欢迎!",
|
||||
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD":"📊 <b>已使用{percent}%流量</b>\n\n您已使用了大部分流量。请注意使用量。",
|
||||
"WEBHOOK_RENEW_BUTTON":"🔄 续订订阅",
|
||||
"WEBHOOK_USER_NOT_CONNECTED":"📡 <b>尚未连接</b>\n\n您的订阅已激活,但尚未建立VPN连接。请连接以开始使用服务。",
|
||||
"WEBHOOK_DEVICE_ADDED":"📱 <b>新设备</b>\n\n您的订阅已添加新设备:<code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED":"📱 <b>设备已移除</b>\n\n设备已从您的订阅中移除:<code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON":"✖️ 关闭"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
@@ -219,6 +220,10 @@ class AuthMiddleware(BaseMiddleware):
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f'Callback data: {event.data}')
|
||||
raise
|
||||
except TelegramForbiddenError:
|
||||
# User blocked the bot — normal, not an error
|
||||
logger.debug('AuthMiddleware: bot blocked by user, skipping')
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в AuthMiddleware: {e}')
|
||||
logger.error(f'Event type: {type(event)}')
|
||||
|
||||
@@ -204,6 +204,6 @@ class ButtonStatsMiddleware(BaseMiddleware):
|
||||
button_text=button_text,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f'Ошибка записи клика в БД {button_id}: {e}')
|
||||
logger.warning(f'Ошибка записи клика в БД {button_id}: {e}')
|
||||
except Exception as e:
|
||||
logger.debug(f'Ошибка создания сессии БД для логирования клика: {e}')
|
||||
logger.warning(f'Ошибка создания сессии БД для логирования клика: {e}')
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
import redis.asyncio as aioredis
|
||||
from aiogram import BaseMiddleware, Bot, types
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
|
||||
|
||||
@@ -109,6 +109,15 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.debug('❌ telegram_id не найден, пропускаем')
|
||||
return await handler(event, data)
|
||||
|
||||
# Skip channel check for lightweight UI callbacks (close/delete notifications)
|
||||
if isinstance(event, CallbackQuery) and event.data in (
|
||||
'webhook:close',
|
||||
'ban_notify:delete',
|
||||
'noop',
|
||||
'current_page',
|
||||
):
|
||||
return await handler(event, data)
|
||||
|
||||
# Админам разрешаем пропускать проверку подписки
|
||||
if settings.is_admin(telegram_id):
|
||||
logger.debug(
|
||||
@@ -188,6 +197,9 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f'❌ Ошибка запроса к каналу {channel_id}: {e}')
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(f'⚠️ Таймаут при проверке подписки на канал: {e}')
|
||||
return await handler(event, data)
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Неожиданная ошибка при проверке подписки: {e}')
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -1216,6 +1216,21 @@ class AdminNotificationService:
|
||||
def _is_enabled(self) -> bool:
|
||||
return self.enabled and bool(self.chat_id)
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""Public check for whether admin notifications are configured and active."""
|
||||
return self._is_enabled()
|
||||
|
||||
async def send_webhook_notification(self, text: str) -> bool:
|
||||
"""Send a generic webhook/infrastructure notification to admin chat.
|
||||
|
||||
Used by RemnaWaveWebhookService for node, service, and CRM events.
|
||||
The caller is responsible for HTML-escaping all untrusted data in `text`.
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: str | None) -> str:
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
@@ -1519,7 +1534,6 @@ class AdminNotificationService:
|
||||
'traffic': '📊 ДОКУПКА ТРАФИКА',
|
||||
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
|
||||
'servers': '🌐 СМЕНА СЕРВЕРОВ',
|
||||
'modem': '📡 МОДЕМ',
|
||||
}
|
||||
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
|
||||
|
||||
@@ -1555,10 +1569,6 @@ class AdminNotificationService:
|
||||
message_lines.append(f'🔄 {old_formatted} → {new_formatted}')
|
||||
elif update_type == 'devices':
|
||||
message_lines.append(f'🔄 {old_value} → {new_value} устр.')
|
||||
elif update_type == 'modem':
|
||||
old_state = '✅ Вкл' if old_value else '❌ Выкл'
|
||||
new_state = '✅ Вкл' if new_value else '❌ Выкл'
|
||||
message_lines.append(f'🔄 {old_state} → {new_state}')
|
||||
else:
|
||||
message_lines.append(f'🔄 {old_value} → {new_value}')
|
||||
|
||||
@@ -1623,8 +1633,6 @@ class AdminNotificationService:
|
||||
if isinstance(value, list):
|
||||
return f'{len(value)} серверов'
|
||||
return str(value)
|
||||
if update_type == 'modem':
|
||||
return '✅ Включён' if value else '❌ Выключен'
|
||||
return str(value)
|
||||
|
||||
async def send_bulk_ban_notification(
|
||||
|
||||
+217
-58
@@ -7,7 +7,7 @@ import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date as dt_date, datetime, time as dt_time, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -24,14 +24,41 @@ from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
BroadcastHistory,
|
||||
ButtonClickLog,
|
||||
CloudPaymentsPayment,
|
||||
ContestAttempt,
|
||||
ContestRound,
|
||||
ContestTemplate,
|
||||
CryptoBotPayment,
|
||||
DiscountOffer,
|
||||
FaqPage,
|
||||
FaqSetting,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MainMenuButton,
|
||||
MenuLayoutHistory,
|
||||
MonitoringLog,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethodConfig,
|
||||
PinnedMessage,
|
||||
PlategaPayment,
|
||||
Poll,
|
||||
PollAnswer,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
PrivacyPolicy,
|
||||
PromoCode,
|
||||
PromoCodeUse,
|
||||
PromoGroup,
|
||||
PromoOfferLog,
|
||||
PromoOfferTemplate,
|
||||
PublicOffer,
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
ReferralEarning,
|
||||
SentNotification,
|
||||
ServerSquad,
|
||||
@@ -39,19 +66,33 @@ from app.database.models import (
|
||||
Squad,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionEvent,
|
||||
SubscriptionServer,
|
||||
SubscriptionTemporaryAccess,
|
||||
SupportAuditLog,
|
||||
SystemSetting,
|
||||
Tariff,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
TicketNotification,
|
||||
TrafficPurchase,
|
||||
Transaction,
|
||||
User,
|
||||
UserMessage,
|
||||
UserPromoGroup,
|
||||
WataPayment,
|
||||
WebApiToken,
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
WelcomeText,
|
||||
WheelConfig,
|
||||
WheelPrize,
|
||||
WheelSpin,
|
||||
WithdrawalRequest,
|
||||
YooKassaPayment,
|
||||
payment_method_promo_groups,
|
||||
server_squad_promo_groups,
|
||||
tariff_promo_groups,
|
||||
)
|
||||
|
||||
|
||||
@@ -122,6 +163,53 @@ class BackupService:
|
||||
TicketMessage,
|
||||
SupportAuditLog,
|
||||
WebApiToken,
|
||||
# --- Payment providers (FK: users, transactions) ---
|
||||
HeleketPayment,
|
||||
WataPayment,
|
||||
PlategaPayment,
|
||||
CloudPaymentsPayment,
|
||||
FreekassaPayment,
|
||||
KassaAiPayment,
|
||||
# --- Settings/content ---
|
||||
PaymentMethodConfig,
|
||||
PrivacyPolicy,
|
||||
PublicOffer,
|
||||
FaqSetting,
|
||||
FaqPage,
|
||||
PinnedMessage,
|
||||
MainMenuButton,
|
||||
MenuLayoutHistory,
|
||||
# --- User data (FK: users, promo_groups, subscriptions) ---
|
||||
UserPromoGroup,
|
||||
TrafficPurchase,
|
||||
SubscriptionEvent,
|
||||
SubscriptionTemporaryAccess,
|
||||
PromoOfferTemplate,
|
||||
PromoOfferLog,
|
||||
# --- Referral/contests (FK: users) ---
|
||||
WithdrawalRequest,
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
ContestTemplate,
|
||||
ContestRound,
|
||||
ContestAttempt,
|
||||
# --- Polls (FK chain: polls -> questions -> options -> answers) ---
|
||||
Poll,
|
||||
PollQuestion,
|
||||
PollOption,
|
||||
PollResponse,
|
||||
PollAnswer,
|
||||
# --- Webhooks ---
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
# --- Wheel (FK chain: configs -> prizes -> spins) ---
|
||||
WheelConfig,
|
||||
WheelPrize,
|
||||
WheelSpin,
|
||||
# --- Support ---
|
||||
TicketNotification,
|
||||
ButtonClickLog,
|
||||
]
|
||||
|
||||
self.backup_models_ordered = self._base_backup_models.copy()
|
||||
@@ -131,6 +219,8 @@ class BackupService:
|
||||
|
||||
self.association_tables = {
|
||||
'server_squad_promo_groups': server_squad_promo_groups,
|
||||
'tariff_promo_groups': tariff_promo_groups,
|
||||
'payment_method_promo_groups': payment_method_promo_groups,
|
||||
}
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
@@ -509,7 +599,7 @@ class BackupService:
|
||||
|
||||
if value is None:
|
||||
record_dict[column.name] = None
|
||||
elif isinstance(value, datetime):
|
||||
elif isinstance(value, (datetime, dt_date, dt_time)):
|
||||
record_dict[column.name] = value.isoformat()
|
||||
elif isinstance(value, (list, dict)):
|
||||
record_dict[column.name] = json_lib.dumps(value) if value else None
|
||||
@@ -538,7 +628,7 @@ class BackupService:
|
||||
|
||||
except Exception as exc:
|
||||
logger.error('Ошибка при экспорте данных: %s', exc)
|
||||
raise exc
|
||||
raise
|
||||
|
||||
async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]:
|
||||
files_info: list[dict[str, Any]] = []
|
||||
@@ -623,7 +713,7 @@ class BackupService:
|
||||
|
||||
mode = 'r:gz' if backup_path.suffixes and backup_path.suffixes[-1] == '.gz' else 'r'
|
||||
with tarfile.open(backup_path, mode) as tar:
|
||||
tar.extractall(temp_path)
|
||||
tar.extractall(temp_path, filter='data')
|
||||
|
||||
metadata_path = temp_path / 'metadata.json'
|
||||
if not metadata_path.exists():
|
||||
@@ -785,20 +875,31 @@ class BackupService:
|
||||
logger.info('📁 Снимок директории data восстановлен')
|
||||
|
||||
async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path):
|
||||
allowed_base = self.data_dir.resolve()
|
||||
|
||||
for file_info in files_info:
|
||||
relative_path = file_info.get('relative_path')
|
||||
target_path = Path(file_info.get('path', ''))
|
||||
if not relative_path or not target_path:
|
||||
continue
|
||||
|
||||
source_file = temp_path / relative_path
|
||||
target_resolved = target_path.resolve()
|
||||
if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base:
|
||||
logger.warning('Заблокирована запись за пределами data_dir: %s', target_path)
|
||||
continue
|
||||
|
||||
source_file = (temp_path / relative_path).resolve()
|
||||
if not str(source_file).startswith(str(temp_path.resolve()) + os.sep):
|
||||
logger.warning('Path traversal в relative_path: %s', relative_path)
|
||||
continue
|
||||
|
||||
if not source_file.exists():
|
||||
logger.warning('Файл %s отсутствует в архиве', relative_path)
|
||||
continue
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(shutil.copy2, source_file, target_path)
|
||||
logger.info('📁 Файл %s восстановлен', target_path)
|
||||
target_resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(shutil.copy2, source_file, target_resolved)
|
||||
logger.info('📁 Файл %s восстановлен', target_resolved)
|
||||
|
||||
async def _restore_database_payload(
|
||||
self,
|
||||
@@ -914,7 +1015,7 @@ class BackupService:
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
logger.error('Ошибка при восстановлении: %s', exc)
|
||||
raise exc
|
||||
raise
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
@@ -994,10 +1095,9 @@ class BackupService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при восстановлении пользователя: {e}')
|
||||
await db.rollback()
|
||||
raise e
|
||||
raise
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info('✅ Пользователи без реферальных связей восстановлены')
|
||||
|
||||
async def _update_user_referrals(self, db: AsyncSession, backup_data: dict):
|
||||
@@ -1031,7 +1131,7 @@ class BackupService:
|
||||
logger.error(f'Ошибка при обновлении реферальной связи: {e}')
|
||||
continue
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info('✅ Реферальные связи обновлены')
|
||||
|
||||
def _process_record_data(self, record_data: dict, model, table_name: str) -> dict:
|
||||
@@ -1058,6 +1158,18 @@ class BackupService:
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}')
|
||||
processed_data[key] = datetime.utcnow()
|
||||
elif column_type_str == 'TIME' and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = dt_time.fromisoformat(value)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f'Не удалось парсить время {value} для поля {key}: {e}')
|
||||
processed_data[key] = dt_time(hour=12, minute=0)
|
||||
elif column_type_str == 'DATE' and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = dt_date.fromisoformat(value)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}')
|
||||
processed_data[key] = None
|
||||
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
|
||||
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif (
|
||||
@@ -1089,11 +1201,8 @@ class BackupService:
|
||||
|
||||
return processed_data
|
||||
|
||||
def _get_primary_key_column(self, model) -> str | None:
|
||||
for col in model.__table__.columns:
|
||||
if col.primary_key:
|
||||
return col.name
|
||||
return None
|
||||
def _get_primary_key_columns(self, model) -> list[str]:
|
||||
return [col.name for col in model.__table__.columns if col.primary_key]
|
||||
|
||||
async def _export_association_tables(self, db: AsyncSession) -> dict[str, list[dict[str, Any]]]:
|
||||
association_data: dict[str, list[dict[str, Any]]] = {}
|
||||
@@ -1119,63 +1228,60 @@ class BackupService:
|
||||
restored_tables = 0
|
||||
restored_records = 0
|
||||
|
||||
if 'server_squad_promo_groups' in association_data:
|
||||
restored = await self._restore_server_squad_promo_groups(
|
||||
db, association_data['server_squad_promo_groups'], clear_existing
|
||||
for table_name, table_obj in self.association_tables.items():
|
||||
if table_name not in association_data:
|
||||
continue
|
||||
col_names = [col.name for col in table_obj.columns]
|
||||
restored = await self._restore_association_table(
|
||||
db, table_obj, table_name, association_data[table_name], clear_existing, col_names
|
||||
)
|
||||
restored_tables += 1
|
||||
restored_records += restored
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
async def _restore_server_squad_promo_groups(
|
||||
self, db: AsyncSession, records: list[dict[str, Any]], clear_existing: bool
|
||||
async def _restore_association_table(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
table_obj,
|
||||
table_name: str,
|
||||
records: list[dict[str, Any]],
|
||||
clear_existing: bool,
|
||||
col_names: list[str],
|
||||
) -> int:
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
if clear_existing:
|
||||
await db.execute(server_squad_promo_groups.delete())
|
||||
await db.execute(table_obj.delete())
|
||||
|
||||
restored = 0
|
||||
|
||||
for record in records:
|
||||
server_id = record.get('server_squad_id')
|
||||
promo_id = record.get('promo_group_id')
|
||||
values = {col: record.get(col) for col in col_names}
|
||||
|
||||
if server_id is None or promo_id is None:
|
||||
logger.warning('Пропущена некорректная запись server_squad_promo_groups: %s', record)
|
||||
if any(v is None for v in values.values()):
|
||||
logger.warning('Пропущена некорректная запись %s: %s', table_name, record)
|
||||
continue
|
||||
|
||||
try:
|
||||
first_col = col_names[0]
|
||||
exists_stmt = (
|
||||
select(server_squad_promo_groups.c.server_squad_id)
|
||||
.where(
|
||||
server_squad_promo_groups.c.server_squad_id == server_id,
|
||||
server_squad_promo_groups.c.promo_group_id == promo_id,
|
||||
)
|
||||
select(table_obj.c[first_col])
|
||||
.where(*[table_obj.c[col] == values[col] for col in col_names])
|
||||
.limit(1)
|
||||
)
|
||||
existing = await db.execute(exists_stmt)
|
||||
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
logger.debug(
|
||||
'Запись server_squad_promo_groups (%s, %s) уже существует',
|
||||
server_id,
|
||||
promo_id,
|
||||
)
|
||||
logger.debug('Запись %s %s уже существует', table_name, values)
|
||||
continue
|
||||
|
||||
await db.execute(
|
||||
server_squad_promo_groups.insert().values(server_squad_id=server_id, promo_group_id=promo_id)
|
||||
)
|
||||
await db.execute(table_obj.insert().values(**values))
|
||||
restored += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s', server_id, promo_id, e
|
||||
)
|
||||
await db.rollback()
|
||||
raise e
|
||||
logger.error('Ошибка при восстановлении связи %s %s: %s', table_name, values, e)
|
||||
raise
|
||||
|
||||
return restored
|
||||
|
||||
@@ -1205,17 +1311,16 @@ class BackupService:
|
||||
logger.warning(f'⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки')
|
||||
processed_data['tariff_id'] = None
|
||||
|
||||
primary_key_col = self._get_primary_key_column(model)
|
||||
pk_cols = self._get_primary_key_columns(model)
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
existing_record = await db.execute(
|
||||
select(model).where(getattr(model, primary_key_col) == processed_data[primary_key_col])
|
||||
)
|
||||
if pk_cols and all(col in processed_data for col in pk_cols):
|
||||
where_clause = [getattr(model, col) == processed_data[col] for col in pk_cols]
|
||||
existing_record = await db.execute(select(model).where(*where_clause))
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
if key not in pk_cols:
|
||||
setattr(existing, key, value)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
@@ -1229,17 +1334,69 @@ class BackupService:
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка восстановления записи в {table_name}: {e}')
|
||||
logger.error(f'Проблемные данные: {record_data}')
|
||||
await db.rollback()
|
||||
raise e
|
||||
raise
|
||||
|
||||
return restored_count
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession, backup_data: dict[str, Any] | None = None):
|
||||
tables_order = [
|
||||
# --- Association tables (no FK deps on them, safe to delete first) ---
|
||||
'server_squad_promo_groups',
|
||||
'tariff_promo_groups',
|
||||
'payment_method_promo_groups',
|
||||
# --- Polls (child -> parent order) ---
|
||||
'poll_answers',
|
||||
'poll_responses',
|
||||
'poll_options',
|
||||
'poll_questions',
|
||||
'polls',
|
||||
# --- Wheel (child -> parent) ---
|
||||
'wheel_spins',
|
||||
'wheel_prizes',
|
||||
'wheel_configs',
|
||||
# --- Contests (child -> parent) ---
|
||||
'contest_attempts',
|
||||
'contest_rounds',
|
||||
'contest_templates',
|
||||
'referral_contest_virtual_participants',
|
||||
'referral_contest_events',
|
||||
'referral_contests',
|
||||
# --- Webhooks ---
|
||||
'webhook_deliveries',
|
||||
'webhooks',
|
||||
# --- Promo offers ---
|
||||
'promo_offer_logs',
|
||||
'promo_offer_templates',
|
||||
'subscription_temporary_access',
|
||||
# --- User engagement ---
|
||||
'subscription_events',
|
||||
'traffic_purchases',
|
||||
'user_promo_groups',
|
||||
'withdrawal_requests',
|
||||
# --- Support extras ---
|
||||
'ticket_notifications',
|
||||
'button_click_logs',
|
||||
# --- Payment providers ---
|
||||
'heleket_payments',
|
||||
'wata_payments',
|
||||
'platega_payments',
|
||||
'cloudpayments_payments',
|
||||
'freekassa_payments',
|
||||
'kassa_ai_payments',
|
||||
# --- Content/config ---
|
||||
'pinned_messages',
|
||||
'main_menu_buttons',
|
||||
'menu_layout_history',
|
||||
'faq_pages',
|
||||
'faq_settings',
|
||||
'privacy_policies',
|
||||
'public_offers',
|
||||
'payment_method_configs',
|
||||
# --- Original tables (preserved order) ---
|
||||
'support_audit_logs',
|
||||
'ticket_messages',
|
||||
'tickets',
|
||||
'support_audit_logs',
|
||||
'cabinet_refresh_tokens',
|
||||
'advertising_campaign_registrations',
|
||||
'advertising_campaigns',
|
||||
'subscription_servers',
|
||||
@@ -1408,9 +1565,11 @@ class BackupService:
|
||||
|
||||
async def delete_backup(self, backup_filename: str) -> tuple[bool, str]:
|
||||
try:
|
||||
backup_path = self.backup_dir / backup_filename
|
||||
backup_path = (self.backup_dir / backup_filename).resolve()
|
||||
if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep):
|
||||
return False, '❌ Недопустимое имя файла бекапа'
|
||||
|
||||
if not backup_path.exists():
|
||||
if not backup_path.is_file():
|
||||
return False, f'❌ Файл бекапа не найден: {backup_filename}'
|
||||
|
||||
backup_path.unlink()
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import calculate_prorated_price
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModemError(Enum):
|
||||
"""Типы ошибок при работе с модемом."""
|
||||
|
||||
NO_SUBSCRIPTION = 'no_subscription'
|
||||
TRIAL_SUBSCRIPTION = 'trial_subscription'
|
||||
MODEM_DISABLED = 'modem_disabled'
|
||||
ALREADY_ENABLED = 'already_enabled'
|
||||
NOT_ENABLED = 'not_enabled'
|
||||
INSUFFICIENT_FUNDS = 'insufficient_funds'
|
||||
CHARGE_ERROR = 'charge_error'
|
||||
UPDATE_ERROR = 'update_error'
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemAvailabilityResult:
|
||||
"""Результат проверки доступности модема."""
|
||||
|
||||
available: bool
|
||||
error: ModemError | None = None
|
||||
modem_enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemPriceResult:
|
||||
"""Результат расчёта цены модема."""
|
||||
|
||||
base_price: int
|
||||
final_price: int
|
||||
discount_percent: int
|
||||
discount_amount: int
|
||||
charged_months: int
|
||||
remaining_days: int
|
||||
end_date: datetime
|
||||
|
||||
@property
|
||||
def has_discount(self) -> bool:
|
||||
return self.discount_percent > 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemEnableResult:
|
||||
"""Результат подключения модема."""
|
||||
|
||||
success: bool
|
||||
error: ModemError | None = None
|
||||
charged_amount: int = 0
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemDisableResult:
|
||||
"""Результат отключения модема."""
|
||||
|
||||
success: bool
|
||||
error: ModemError | None = None
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
# Константы для предупреждений о сроке действия
|
||||
MODEM_WARNING_DAYS_CRITICAL = 7
|
||||
MODEM_WARNING_DAYS_INFO = 30
|
||||
|
||||
|
||||
class ModemService:
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Инкапсулирует всю бизнес-логику:
|
||||
- Проверки доступности
|
||||
- Расчёт цен и скидок
|
||||
- Подключение/отключение модема
|
||||
- Синхронизация с RemnaWave
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._subscription_service = SubscriptionService()
|
||||
|
||||
@staticmethod
|
||||
def is_modem_feature_enabled() -> bool:
|
||||
"""Проверяет, включена ли функция модема в настройках."""
|
||||
return settings.is_modem_enabled()
|
||||
|
||||
@staticmethod
|
||||
def get_modem_enabled(subscription: Subscription | None) -> bool:
|
||||
"""Безопасно получает статус модема из подписки."""
|
||||
if subscription is None:
|
||||
return False
|
||||
return getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
def check_availability(
|
||||
self, user: User, for_enable: bool = False, for_disable: bool = False
|
||||
) -> ModemAvailabilityResult:
|
||||
"""
|
||||
Проверяет доступность модема для пользователя.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
for_enable: Проверка для подключения (модем должен быть отключен)
|
||||
for_disable: Проверка для отключения (модем должен быть включен)
|
||||
|
||||
Returns:
|
||||
ModemAvailabilityResult с результатом проверки
|
||||
"""
|
||||
subscription = user.subscription
|
||||
modem_enabled = self.get_modem_enabled(subscription)
|
||||
|
||||
if not subscription:
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.NO_SUBSCRIPTION, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.TRIAL_SUBSCRIPTION, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if not self.is_modem_feature_enabled():
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.MODEM_DISABLED, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_enable and modem_enabled:
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.ALREADY_ENABLED, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_disable and not modem_enabled:
|
||||
return ModemAvailabilityResult(available=False, error=ModemError.NOT_ENABLED, modem_enabled=modem_enabled)
|
||||
|
||||
return ModemAvailabilityResult(available=True, modem_enabled=modem_enabled)
|
||||
|
||||
def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
|
||||
"""
|
||||
Рассчитывает стоимость подключения модема.
|
||||
|
||||
Использует пропорциональную цену на основе оставшегося времени подписки
|
||||
и применяет скидки в зависимости от периода.
|
||||
|
||||
Args:
|
||||
subscription: Подписка пользователя
|
||||
|
||||
Returns:
|
||||
ModemPriceResult с детализацией цены
|
||||
"""
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
base_price, charged_months = calculate_prorated_price(
|
||||
modem_price_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
remaining_days = max(0, (subscription.end_date - now).days)
|
||||
|
||||
discount_percent = settings.get_modem_period_discount(charged_months)
|
||||
if discount_percent > 0:
|
||||
discount_amount = base_price * discount_percent // 100
|
||||
final_price = base_price - discount_amount
|
||||
else:
|
||||
discount_amount = 0
|
||||
final_price = base_price
|
||||
|
||||
return ModemPriceResult(
|
||||
base_price=base_price,
|
||||
final_price=final_price,
|
||||
discount_percent=discount_percent,
|
||||
discount_amount=discount_amount,
|
||||
charged_months=charged_months,
|
||||
remaining_days=remaining_days,
|
||||
end_date=subscription.end_date,
|
||||
)
|
||||
|
||||
def check_balance(self, user: User, price: int) -> tuple[bool, int]:
|
||||
"""
|
||||
Проверяет достаточность баланса.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
price: Требуемая сумма
|
||||
|
||||
Returns:
|
||||
Tuple[достаточно ли средств, недостающая сумма]
|
||||
"""
|
||||
if price <= 0:
|
||||
return True, 0
|
||||
|
||||
if user.balance_kopeks >= price:
|
||||
return True, 0
|
||||
|
||||
missing = price - user.balance_kopeks
|
||||
return False, missing
|
||||
|
||||
async def enable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemEnableResult:
|
||||
"""
|
||||
Подключает модем к подписке.
|
||||
|
||||
Выполняет:
|
||||
1. Расчёт цены
|
||||
2. Проверку баланса
|
||||
3. Списание средств
|
||||
4. Создание транзакции
|
||||
5. Обновление подписки
|
||||
6. Синхронизацию с RemnaWave
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemEnableResult с результатом операции
|
||||
"""
|
||||
price_info = self.calculate_price(subscription)
|
||||
price = price_info.final_price
|
||||
|
||||
has_funds, _ = self.check_balance(user, price)
|
||||
if not has_funds:
|
||||
return ModemEnableResult(success=False, error=ModemError.INSUFFICIENT_FUNDS)
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(db, user, price, 'Подключение модема')
|
||||
|
||||
if not success:
|
||||
return ModemEnableResult(success=False, error=ModemError.CHARGE_ERROR)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f'Подключение модема на {price_info.charged_months} мес',
|
||||
)
|
||||
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(f'Пользователь {user_id_display} подключил модем, списано: {price / 100}₽')
|
||||
|
||||
return ModemEnableResult(success=True, charged_amount=price, new_device_limit=subscription.device_limit)
|
||||
|
||||
except Exception as e:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.error(f'Ошибка подключения модема для пользователя {user_id_display}: {e}')
|
||||
await db.rollback()
|
||||
return ModemEnableResult(success=False, error=ModemError.UPDATE_ERROR)
|
||||
|
||||
async def disable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemDisableResult:
|
||||
"""
|
||||
Отключает модем от подписки.
|
||||
|
||||
Возврат средств не производится.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemDisableResult с результатом операции
|
||||
"""
|
||||
try:
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(f'Пользователь {user_id_display} отключил модем')
|
||||
|
||||
return ModemDisableResult(success=True, new_device_limit=subscription.device_limit)
|
||||
|
||||
except Exception as e:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.error(f'Ошибка отключения модема для пользователя {user_id_display}: {e}')
|
||||
await db.rollback()
|
||||
return ModemDisableResult(success=False, error=ModemError.UPDATE_ERROR)
|
||||
|
||||
@staticmethod
|
||||
def get_period_warning_level(remaining_days: int) -> str | None:
|
||||
"""
|
||||
Определяет уровень предупреждения о сроке действия.
|
||||
|
||||
Args:
|
||||
remaining_days: Оставшиеся дни подписки
|
||||
|
||||
Returns:
|
||||
"critical" если <= 7 дней
|
||||
"info" если <= 30 дней
|
||||
None если больше 30 дней
|
||||
"""
|
||||
if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
|
||||
return 'critical'
|
||||
if remaining_days <= MODEM_WARNING_DAYS_INFO:
|
||||
return 'info'
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance для использования в хендлерах
|
||||
_modem_service: ModemService | None = None
|
||||
|
||||
|
||||
def get_modem_service() -> ModemService:
|
||||
"""Возвращает singleton экземпляр ModemService."""
|
||||
global _modem_service
|
||||
if _modem_service is None:
|
||||
_modem_service = ModemService()
|
||||
return _modem_service
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -262,9 +262,18 @@ class MonitoringService:
|
||||
|
||||
async def _check_expired_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск expire подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
from app.database.crud.subscription import expire_subscription
|
||||
|
||||
await expire_subscription(db, subscription)
|
||||
@@ -288,6 +297,15 @@ class MonitoringService:
|
||||
|
||||
async def update_remnawave_user(self, db: AsyncSession, subscription: Subscription) -> RemnaWaveUser | None:
|
||||
try:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
return None
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f'RemnaWave UUID не найден для пользователя {subscription.user_id}')
|
||||
@@ -299,6 +317,14 @@ class MonitoringService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-check guard after refresh (webhook could have committed between first check and refresh)
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно (после refresh)',
|
||||
subscription.id,
|
||||
)
|
||||
return None
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time
|
||||
|
||||
@@ -550,6 +576,8 @@ class MonitoringService:
|
||||
logger.error(f'Ошибка проверки неактивных тестовых подписок: {e}')
|
||||
|
||||
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return
|
||||
|
||||
@@ -636,6 +664,12 @@ class MonitoringService:
|
||||
continue
|
||||
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации trial подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
subscription = await deactivate_subscription(db, subscription)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
@@ -670,6 +704,12 @@ class MonitoringService:
|
||||
'trial_channel_unsubscribed',
|
||||
)
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск реактивации trial подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -1007,6 +1047,15 @@ class MonitoringService:
|
||||
failed_count = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск автоплатежа подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
@@ -1237,6 +1286,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об истечении подписки пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об истечении подписки пользователю %s: %s',
|
||||
@@ -1286,6 +1342,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об окончании тестовой подписки пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s',
|
||||
@@ -1361,6 +1424,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об отсутствии подключения пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s',
|
||||
@@ -1424,6 +1494,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as error:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об отписке от канала пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об отписке от канала пользователю %s: %s',
|
||||
@@ -1488,6 +1565,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки напоминания об истекшей подписке пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки напоминания об истекшей подписке пользователю %s: %s',
|
||||
@@ -1580,6 +1664,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки скидочного уведомления пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки скидочного уведомления пользователю %s: %s',
|
||||
@@ -1604,6 +1695,12 @@ class MonitoringService:
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об автоплатеже пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об автоплатеже пользователю %s: %s',
|
||||
@@ -1641,6 +1738,12 @@ class MonitoringService:
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления о неудачном автоплатеже пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s',
|
||||
@@ -1874,11 +1977,19 @@ class MonitoringService:
|
||||
}
|
||||
|
||||
async def force_check_subscriptions(self, db: AsyncSession) -> dict[str, int]:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
expired_count = 0
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск force-check подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
await deactivate_subscription(db, subscription)
|
||||
expired_count += 1
|
||||
|
||||
|
||||
@@ -57,6 +57,21 @@ class NotificationType(Enum):
|
||||
EMAIL_VERIFICATION = 'email_verification'
|
||||
PASSWORD_RESET = 'password_reset'
|
||||
|
||||
# Webhook subscription events
|
||||
WEBHOOK_SUB_EXPIRED = 'webhook_sub_expired'
|
||||
WEBHOOK_SUB_DISABLED = 'webhook_sub_disabled'
|
||||
WEBHOOK_SUB_ENABLED = 'webhook_sub_enabled'
|
||||
WEBHOOK_SUB_LIMITED = 'webhook_sub_limited'
|
||||
WEBHOOK_SUB_TRAFFIC_RESET = 'webhook_sub_traffic_reset'
|
||||
WEBHOOK_SUB_DELETED = 'webhook_sub_deleted'
|
||||
WEBHOOK_SUB_REVOKED = 'webhook_sub_revoked'
|
||||
WEBHOOK_SUB_EXPIRING = 'webhook_sub_expiring'
|
||||
WEBHOOK_SUB_FIRST_CONNECTED = 'webhook_sub_first_connected'
|
||||
WEBHOOK_SUB_BANDWIDTH_THRESHOLD = 'webhook_sub_bandwidth_threshold'
|
||||
WEBHOOK_USER_NOT_CONNECTED = 'webhook_user_not_connected'
|
||||
WEBHOOK_DEVICE_ADDED = 'webhook_device_added'
|
||||
WEBHOOK_DEVICE_DELETED = 'webhook_device_deleted'
|
||||
|
||||
# Other
|
||||
BROADCAST = 'broadcast'
|
||||
PAYMENT_RECEIVED = 'payment_received'
|
||||
|
||||
@@ -237,6 +237,7 @@ class CloudPaymentsPaymentMixin:
|
||||
payment_method=PaymentMethod.CLOUDPAYMENTS,
|
||||
external_id=str(transaction_id_cp) if transaction_id_cp else invoice_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
payment.transaction_id = transaction.id
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import MissingGreenlet
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.database import get_db
|
||||
from app.database.database import AsyncSessionLocal, get_db
|
||||
from app.database.models import Subscription
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
@@ -46,12 +49,26 @@ class PaymentCommonMixin:
|
||||
and not getattr(subscription, 'is_trial', False)
|
||||
and getattr(subscription, 'is_active', False)
|
||||
)
|
||||
except MissingGreenlet as error:
|
||||
logger.warning(
|
||||
'Не удалось лениво загрузить подписку пользователя %s при построении клавиатуры после пополнения: %s',
|
||||
getattr(user, 'id', None),
|
||||
error,
|
||||
)
|
||||
except MissingGreenlet:
|
||||
# user вне сессии — загружаем подписку отдельным запросом
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Subscription.status, Subscription.is_trial, Subscription.end_date)
|
||||
.where(Subscription.user_id == user.id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row:
|
||||
is_active = row.status == 'active' and row.end_date > datetime.utcnow()
|
||||
has_active_subscription = bool(is_active and not row.is_trial)
|
||||
except Exception as db_error:
|
||||
logger.warning(
|
||||
'Не удалось загрузить подписку пользователя %s из БД: %s',
|
||||
getattr(user, 'id', None),
|
||||
db_error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - защитный код
|
||||
logger.error(
|
||||
'Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s',
|
||||
|
||||
@@ -156,8 +156,10 @@ class CryptoBotPaymentMixin:
|
||||
cryptobot_crud = import_module('app.database.crud.cryptobot')
|
||||
payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
if not payment:
|
||||
logger.error('CryptoBot платеж не найден в БД: %s', invoice_id)
|
||||
return False
|
||||
logger.warning(
|
||||
'CryptoBot платеж не найден в БД: %s (возвращаем 200 чтобы остановить ретраи)', invoice_id
|
||||
)
|
||||
return True
|
||||
|
||||
if payment.status == 'paid':
|
||||
logger.info('CryptoBot платеж %s уже обработан', invoice_id)
|
||||
@@ -251,6 +253,7 @@ class CryptoBotPaymentMixin:
|
||||
payment_method=PaymentMethod.CRYPTOBOT,
|
||||
external_id=invoice_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(updated_payment, 'created_at', None),
|
||||
)
|
||||
|
||||
await cryptobot_crud.link_cryptobot_payment_to_transaction(db, invoice_id, transaction.id)
|
||||
|
||||
@@ -279,6 +279,7 @@ class FreekassaPaymentMixin:
|
||||
payment_method=PaymentMethod.FREEKASSA,
|
||||
external_id=str(intid) if intid else payment.order_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
|
||||
@@ -260,7 +260,10 @@ class HeleketPaymentMixin:
|
||||
invoice_message = metadata.get('invoice_message') or {}
|
||||
invoice_message_removed = False
|
||||
|
||||
if getattr(self, 'bot', None) and invoice_message:
|
||||
status_normalized = (status or '').lower()
|
||||
is_final = status_normalized in {'paid', 'paid_over', 'cancel', 'fail', 'system_fail', 'refund_paid'}
|
||||
|
||||
if getattr(self, 'bot', None) and invoice_message and is_final:
|
||||
chat_id = invoice_message.get('chat_id')
|
||||
message_id = invoice_message.get('message_id')
|
||||
if chat_id and message_id:
|
||||
@@ -300,7 +303,6 @@ class HeleketPaymentMixin:
|
||||
)
|
||||
return updated_payment
|
||||
|
||||
status_normalized = (status or '').lower()
|
||||
if status_normalized not in {'paid', 'paid_over'}:
|
||||
logger.info('Heleket платеж %s в статусе %s, зачисление не требуется', updated_payment.uuid, status)
|
||||
return updated_payment
|
||||
@@ -323,6 +325,7 @@ class HeleketPaymentMixin:
|
||||
payment_method=PaymentMethod.HELEKET,
|
||||
external_id=updated_payment.uuid,
|
||||
is_completed=True,
|
||||
created_at=getattr(updated_payment, 'created_at', None),
|
||||
)
|
||||
|
||||
linked_payment = await heleket_crud.link_heleket_payment_to_transaction(
|
||||
|
||||
@@ -272,6 +272,7 @@ class KassaAiPaymentMixin:
|
||||
payment_method=PaymentMethod.KASSA_AI,
|
||||
external_id=str(intid) if intid else payment.order_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
|
||||
@@ -75,7 +75,7 @@ class MulenPayPaymentMixin:
|
||||
uuid=payment_uuid,
|
||||
items=items,
|
||||
language=language or settings.MULENPAY_LANGUAGE,
|
||||
website_url=settings.WEBHOOK_URL,
|
||||
website_url=settings.MULENPAY_WEBSITE_URL or settings.WEBHOOK_URL,
|
||||
)
|
||||
|
||||
if not response:
|
||||
@@ -253,6 +253,7 @@ class MulenPayPaymentMixin:
|
||||
payment_method=PaymentMethod.MULENPAY,
|
||||
external_id=payment.uuid,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
await payment_module.link_mulenpay_payment_to_transaction(
|
||||
|
||||
@@ -385,6 +385,7 @@ class Pal24PaymentMixin:
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
@@ -366,6 +366,7 @@ class PlategaPaymentMixin:
|
||||
payment_method=PaymentMethod.PLATEGA,
|
||||
external_id=transaction_external_id or payment.correlation_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
|
||||
@@ -472,6 +472,7 @@ class WataPaymentMixin:
|
||||
payment_method=PaymentMethod.WATA,
|
||||
external_id=transaction_external_id or payment.payment_link_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
@@ -397,6 +397,21 @@ class YooKassaPaymentMixin:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import YooKassaPayment as YKPayment
|
||||
|
||||
# Lock the payment row to prevent concurrent double-processing
|
||||
locked_result = await db.execute(select(YKPayment).where(YKPayment.id == payment.id).with_for_update())
|
||||
payment = locked_result.scalar_one()
|
||||
|
||||
# Fast-path: already processed
|
||||
if getattr(payment, 'transaction_id', None):
|
||||
logger.info(
|
||||
'Платеж YooKassa %s уже обработан (transaction_id=%s), пропускаем.',
|
||||
payment.yookassa_payment_id,
|
||||
payment.transaction_id,
|
||||
)
|
||||
return True
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
|
||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||
@@ -589,6 +604,7 @@ class YooKassaPaymentMixin:
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
if not getattr(payment, 'transaction_id', None):
|
||||
|
||||
@@ -1335,9 +1335,18 @@ class RemnaWaveService:
|
||||
pending_uuid_mutations.clear()
|
||||
try:
|
||||
await db.rollback() # Выполняем rollback при ошибке
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# After rollback all ORM objects in the session are expired.
|
||||
# Accessing their attributes triggers a lazy load which fails
|
||||
# in async context (greenlet_spawn error). Break the loop to
|
||||
# prevent cascading failures for every remaining user.
|
||||
logger.warning(
|
||||
'⚠️ Сессия повреждена после rollback, прерываем обработку (обработано %d/%d пользователей)',
|
||||
i + 1,
|
||||
len(unique_panel_users),
|
||||
)
|
||||
break
|
||||
|
||||
else:
|
||||
if uuid_mutation and uuid_mutation.has_changes():
|
||||
@@ -1454,10 +1463,20 @@ class RemnaWaveService:
|
||||
for telegram_id, db_user in users_to_deactivate:
|
||||
cleanup_mutation: _UUIDMapMutation | None = None
|
||||
try:
|
||||
logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)')
|
||||
|
||||
subscription = db_user.subscription
|
||||
|
||||
# Skip if recently updated by webhook
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if subscription and is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)')
|
||||
|
||||
if db_user.remnawave_uuid and hwid_api_client:
|
||||
try:
|
||||
devices_reset = await hwid_api_client.reset_user_devices(db_user.remnawave_uuid)
|
||||
@@ -1664,7 +1683,7 @@ class RemnaWaveService:
|
||||
|
||||
async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user):
|
||||
try:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, is_recently_updated_by_webhook
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Всегда используем async CRUD запрос для получения подписки,
|
||||
@@ -1675,6 +1694,14 @@ class RemnaWaveService:
|
||||
await self._create_subscription_from_panel_data(db, user, panel_user)
|
||||
return
|
||||
|
||||
# Skip if recently updated by webhook (prevent stale data overwrite)
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск синхронизации подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
return
|
||||
|
||||
panel_status = panel_user.get('status', 'ACTIVE')
|
||||
expire_at_str = panel_user.get('expireAt', '')
|
||||
|
||||
@@ -2499,12 +2526,20 @@ class RemnaWaveService:
|
||||
await self._update_subscription_from_panel_data(db, user, panel_user)
|
||||
stats['updated'] += 1
|
||||
elif subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)')
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
is_recently_updated_by_webhook,
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
stats['updated'] += 1
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
else:
|
||||
logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)')
|
||||
await deactivate_subscription(db, subscription)
|
||||
stats['updated'] += 1
|
||||
|
||||
except Exception as sub_error:
|
||||
logger.error(f'❌ Ошибка синхронизации подписки {subscription.id}: {sub_error}')
|
||||
@@ -2547,6 +2582,8 @@ class RemnaWaveService:
|
||||
user = subscription.user
|
||||
issues_fixed = 0
|
||||
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
current_time = self._now_utc()
|
||||
# Конвертируем end_date в UTC для корректного сравнения
|
||||
end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
@@ -2555,6 +2592,7 @@ class RemnaWaveService:
|
||||
if (
|
||||
end_date_utc + expiry_buffer <= current_time
|
||||
and subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and not is_recently_updated_by_webhook(subscription)
|
||||
):
|
||||
time_since_expiry = current_time - end_date_utc
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
"""
|
||||
Service for processing incoming RemnaWave backend webhooks.
|
||||
|
||||
Handles all webhook scopes: user, user_hwid_devices, node, service, crm.
|
||||
User events update subscription state and notify the user.
|
||||
Admin events (node, service, crm) send alerts to the admin notification chat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
decrement_subscription_server_counts,
|
||||
expire_subscription,
|
||||
get_subscription_by_user_id,
|
||||
reactivate_subscription,
|
||||
update_subscription_usage,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_remnawave_uuid, get_user_by_telegram_id
|
||||
from app.database.models import Subscription, SubscriptionServer, SubscriptionStatus, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.notification_delivery_service import NotificationType, notification_delivery_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Mapping from locale text_key to NotificationType for unified delivery
|
||||
_TEXT_KEY_TO_NOTIFICATION_TYPE: dict[str, NotificationType] = {
|
||||
'WEBHOOK_SUB_EXPIRED': NotificationType.WEBHOOK_SUB_EXPIRED,
|
||||
'WEBHOOK_SUB_DISABLED': NotificationType.WEBHOOK_SUB_DISABLED,
|
||||
'WEBHOOK_SUB_ENABLED': NotificationType.WEBHOOK_SUB_ENABLED,
|
||||
'WEBHOOK_SUB_LIMITED': NotificationType.WEBHOOK_SUB_LIMITED,
|
||||
'WEBHOOK_SUB_TRAFFIC_RESET': NotificationType.WEBHOOK_SUB_TRAFFIC_RESET,
|
||||
'WEBHOOK_SUB_DELETED': NotificationType.WEBHOOK_SUB_DELETED,
|
||||
'WEBHOOK_SUB_REVOKED': NotificationType.WEBHOOK_SUB_REVOKED,
|
||||
'WEBHOOK_SUB_EXPIRES_72H': NotificationType.WEBHOOK_SUB_EXPIRING,
|
||||
'WEBHOOK_SUB_EXPIRES_48H': NotificationType.WEBHOOK_SUB_EXPIRING,
|
||||
'WEBHOOK_SUB_EXPIRES_24H': NotificationType.WEBHOOK_SUB_EXPIRING,
|
||||
'WEBHOOK_SUB_EXPIRED_24H_AGO': NotificationType.WEBHOOK_SUB_EXPIRED,
|
||||
'WEBHOOK_SUB_FIRST_CONNECTED': NotificationType.WEBHOOK_SUB_FIRST_CONNECTED,
|
||||
'WEBHOOK_SUB_BANDWIDTH_THRESHOLD': NotificationType.WEBHOOK_SUB_BANDWIDTH_THRESHOLD,
|
||||
'WEBHOOK_USER_NOT_CONNECTED': NotificationType.WEBHOOK_USER_NOT_CONNECTED,
|
||||
'WEBHOOK_DEVICE_ADDED': NotificationType.WEBHOOK_DEVICE_ADDED,
|
||||
'WEBHOOK_DEVICE_DELETED': NotificationType.WEBHOOK_DEVICE_DELETED,
|
||||
}
|
||||
|
||||
# Admin event display names for notification messages
|
||||
_ADMIN_NODE_EVENTS: dict[str, str] = {
|
||||
'node.created': '🟢 Нода создана',
|
||||
'node.modified': '🔧 Нода изменена',
|
||||
'node.disabled': '🔴 Нода отключена',
|
||||
'node.enabled': '🟢 Нода включена',
|
||||
'node.deleted': '🗑️ Нода удалена',
|
||||
'node.connection_lost': '🚨 Потеряно соединение с нодой',
|
||||
'node.connection_restored': '✅ Соединение с нодой восстановлено',
|
||||
'node.traffic_notify': '📊 Уведомление о трафике ноды',
|
||||
}
|
||||
|
||||
_ADMIN_SERVICE_EVENTS: dict[str, str] = {
|
||||
'service.panel_started': '🚀 Панель RemnaWave запущена',
|
||||
'service.login_attempt_failed': '🔐 Неудачная попытка входа в панель',
|
||||
'service.login_attempt_success': '🔓 Успешный вход в панель',
|
||||
'service.subpage_config_changed': '📄 Конфиг страницы подписки изменён',
|
||||
}
|
||||
|
||||
_ADMIN_CRM_EVENTS: dict[str, str] = {
|
||||
'crm.infra_billing_node_payment_in_7_days': '💳 Оплата ноды через 7 дней',
|
||||
'crm.infra_billing_node_payment_in_48hrs': '💳 Оплата ноды через 48 часов',
|
||||
'crm.infra_billing_node_payment_in_24hrs': '⚠️ Оплата ноды через 24 часа',
|
||||
'crm.infra_billing_node_payment_due_today': '🔴 Оплата ноды сегодня',
|
||||
'crm.infra_billing_node_payment_overdue_24hrs': '❗ Просрочка оплаты ноды: 24 часа',
|
||||
'crm.infra_billing_node_payment_overdue_48hrs': '❗ Просрочка оплаты ноды: 48 часов',
|
||||
'crm.infra_billing_node_payment_overdue_7_days': '🚨 Просрочка оплаты ноды: 7 дней',
|
||||
}
|
||||
|
||||
_ADMIN_ERROR_EVENTS: dict[str, str] = {
|
||||
'errors.bandwidth_usage_threshold_reached_max_notifications': '⚠️ Достигнут лимит уведомлений о трафике',
|
||||
}
|
||||
|
||||
|
||||
class RemnaWaveWebhookService:
|
||||
"""Processes incoming webhooks from RemnaWave backend."""
|
||||
|
||||
def __init__(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
self._admin_service = AdminNotificationService(bot)
|
||||
|
||||
# User-scoped handlers: require user resolution
|
||||
self._user_handlers: dict[str, Any] = {
|
||||
'user.expired': self._handle_user_expired,
|
||||
'user.disabled': self._handle_user_disabled,
|
||||
'user.enabled': self._handle_user_enabled,
|
||||
'user.limited': self._handle_user_limited,
|
||||
'user.traffic_reset': self._handle_user_traffic_reset,
|
||||
'user.modified': self._handle_user_modified,
|
||||
'user.deleted': self._handle_user_deleted,
|
||||
'user.revoked': self._handle_user_revoked,
|
||||
'user.created': self._handle_user_created,
|
||||
'user.expires_in_72_hours': self._handle_expires_in_72h,
|
||||
'user.expires_in_48_hours': self._handle_expires_in_48h,
|
||||
'user.expires_in_24_hours': self._handle_expires_in_24h,
|
||||
'user.expired_24_hours_ago': self._handle_expired_24h_ago,
|
||||
'user.first_connected': self._handle_first_connected,
|
||||
'user.bandwidth_usage_threshold_reached': self._handle_bandwidth_threshold,
|
||||
'user.not_connected': self._handle_user_not_connected,
|
||||
'user_hwid_devices.added': self._handle_device_added,
|
||||
'user_hwid_devices.deleted': self._handle_device_deleted,
|
||||
}
|
||||
|
||||
# Admin-scoped handlers: no user resolution, notify admin chat
|
||||
self._admin_handlers: dict[str, str] = {
|
||||
**_ADMIN_NODE_EVENTS,
|
||||
**_ADMIN_SERVICE_EVENTS,
|
||||
**_ADMIN_CRM_EVENTS,
|
||||
**_ADMIN_ERROR_EVENTS,
|
||||
}
|
||||
|
||||
def is_admin_event(self, event_name: str) -> bool:
|
||||
"""Check if the event is admin-scoped (no DB session needed)."""
|
||||
return event_name in self._admin_handlers
|
||||
|
||||
async def process_event(self, db: AsyncSession | None, event_name: str, data: dict) -> bool:
|
||||
"""Route event to the appropriate handler.
|
||||
|
||||
Returns True if the event was processed, False if skipped/unknown.
|
||||
db may be None for admin events that don't require database access.
|
||||
"""
|
||||
# Check admin-scoped handlers (no DB needed)
|
||||
if event_name in self._admin_handlers:
|
||||
return await self._process_admin_event(event_name, data)
|
||||
|
||||
# Check user-scoped handlers (require DB session)
|
||||
user_handler = self._user_handlers.get(event_name)
|
||||
if user_handler:
|
||||
if db is None:
|
||||
logger.error('RemnaWave webhook: DB session required for user event %s', event_name)
|
||||
return False
|
||||
return await self._process_user_event(db, event_name, data, user_handler)
|
||||
|
||||
logger.debug('Unhandled RemnaWave webhook event: %s', event_name)
|
||||
return False
|
||||
|
||||
async def _process_user_event(self, db: AsyncSession, event_name: str, data: dict, handler: Any) -> bool:
|
||||
"""Resolve user and execute user-scoped handler."""
|
||||
user, subscription = await self._resolve_user_and_subscription(db, data)
|
||||
if not user:
|
||||
logger.warning(
|
||||
'RemnaWave webhook: user not found for event %s, data telegramId=%s uuid=%s',
|
||||
event_name,
|
||||
data.get('telegramId'),
|
||||
data.get('uuid'),
|
||||
)
|
||||
return False
|
||||
|
||||
user_id = user.id
|
||||
try:
|
||||
await handler(db, user, subscription, data)
|
||||
return True
|
||||
except StaleDataError:
|
||||
logger.warning(
|
||||
'RemnaWave webhook %s: entity already deleted for user %s (concurrent deletion)',
|
||||
event_name,
|
||||
user_id,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('Error processing RemnaWave webhook event %s for user %s', event_name, user_id)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
logger.debug('Rollback after webhook handler error also failed')
|
||||
return False
|
||||
|
||||
async def _process_admin_event(self, event_name: str, data: dict) -> bool:
|
||||
"""Format and send admin notification for infrastructure events."""
|
||||
if not self._admin_service.is_enabled:
|
||||
logger.debug('Admin notifications disabled, skipping event %s', event_name)
|
||||
return True
|
||||
|
||||
title = self._admin_handlers.get(event_name, event_name)
|
||||
|
||||
# Build message from event data (escape all untrusted values to prevent HTML injection)
|
||||
lines = [f'<b>{title}</b>']
|
||||
|
||||
# Extract common fields
|
||||
name = html.escape(data.get('name') or data.get('nodeName') or data.get('username') or '')
|
||||
if name:
|
||||
lines.append(f'Имя: <code>{name}</code>')
|
||||
|
||||
address = html.escape(data.get('address') or data.get('ip') or '')
|
||||
if address:
|
||||
lines.append(f'Адрес: <code>{address}</code>')
|
||||
|
||||
port = data.get('port')
|
||||
if port:
|
||||
lines.append(f'Порт: <code>{html.escape(str(port))}</code>')
|
||||
|
||||
version = html.escape(data.get('version') or data.get('panelVersion') or '')
|
||||
if version:
|
||||
lines.append(f'Версия: <code>{version}</code>')
|
||||
|
||||
# CRM billing fields
|
||||
amount = html.escape(str(data.get('amount') or data.get('price') or ''))
|
||||
if amount:
|
||||
lines.append(f'Сумма: <code>{amount}</code>')
|
||||
|
||||
due_date = html.escape(data.get('dueDate') or data.get('paymentDate') or '')
|
||||
if due_date:
|
||||
lines.append(f'Дата: <code>{due_date}</code>')
|
||||
|
||||
# Login attempt fields
|
||||
ip_addr = html.escape(data.get('ipAddress') or data.get('ip') or '')
|
||||
if ip_addr and not address:
|
||||
lines.append(f'IP: <code>{ip_addr}</code>')
|
||||
|
||||
message = html.escape(data.get('message') or '')
|
||||
if message:
|
||||
lines.append(f'Сообщение: {message}')
|
||||
|
||||
# Subpage config fields
|
||||
subpage = data.get('subpageConfig')
|
||||
if isinstance(subpage, dict):
|
||||
action = subpage.get('action', '')
|
||||
action_labels = {'CREATED': 'Создан', 'UPDATED': 'Обновлён', 'DELETED': 'Удалён'}
|
||||
lines.append(f'Действие: {action_labels.get(action, html.escape(str(action)))}')
|
||||
sub_uuid = subpage.get('uuid', '')
|
||||
if sub_uuid:
|
||||
lines.append(f'UUID: <code>{html.escape(str(sub_uuid))}</code>')
|
||||
|
||||
try:
|
||||
await self._admin_service.send_webhook_notification('\n'.join(lines))
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('Failed to send admin notification for event %s', event_name)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _resolve_user_and_subscription(
|
||||
self, db: AsyncSession, data: dict
|
||||
) -> tuple[User | None, Subscription | None]:
|
||||
"""Find bot user by telegramId or uuid from webhook payload.
|
||||
|
||||
Handles both user-scope events (top-level telegramId/uuid) and
|
||||
device-scope events (userUuid, or nested user.telegramId/user.uuid).
|
||||
"""
|
||||
user: User | None = None
|
||||
|
||||
# Try top-level telegramId first
|
||||
telegram_id = data.get('telegramId')
|
||||
if telegram_id:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, int(telegram_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Try top-level uuid
|
||||
if not user:
|
||||
uuid = data.get('uuid') or data.get('userUuid')
|
||||
if uuid:
|
||||
user = await get_user_by_remnawave_uuid(db, uuid)
|
||||
|
||||
# Try nested user object (e.g. user_hwid_devices events)
|
||||
if not user:
|
||||
nested_user = data.get('user')
|
||||
if isinstance(nested_user, dict):
|
||||
nested_tid = nested_user.get('telegramId')
|
||||
if nested_tid:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, int(nested_tid))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if not user:
|
||||
nested_uuid = nested_user.get('uuid')
|
||||
if nested_uuid:
|
||||
user = await get_user_by_remnawave_uuid(db, nested_uuid)
|
||||
|
||||
if not user:
|
||||
return None, None
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
return user, subscription
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notification helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_url(value: str) -> bool:
|
||||
"""Basic URL validation to prevent stored XSS via crafted URLs."""
|
||||
if not value or len(value) > 2048:
|
||||
return False
|
||||
return bool(re.match(r'^https?://', value))
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_link(value: str) -> bool:
|
||||
"""Validate URL or deep link (happ://, vless://, ss://, etc.)."""
|
||||
if not value or len(value) > 4096:
|
||||
return False
|
||||
return bool(re.match(r'^[a-zA-Z][a-zA-Z0-9+\-.]*://', value))
|
||||
|
||||
def _get_renew_keyboard(self, user: User) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language)
|
||||
button_text = texts.get('WEBHOOK_RENEW_BUTTON', 'Renew subscription')
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription_extend')],
|
||||
]
|
||||
)
|
||||
|
||||
def _get_subscription_keyboard(self, user: User) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language)
|
||||
button_text = texts.get('MY_SUBSCRIPTION_BUTTON', 'My subscription')
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription')],
|
||||
]
|
||||
)
|
||||
|
||||
def _get_connect_keyboard(self, user: User) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language)
|
||||
button_text = texts.get('CONNECT_BUTTON', 'Connect')
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription_connect')],
|
||||
]
|
||||
)
|
||||
|
||||
def _get_traffic_keyboard(self, user: User) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language)
|
||||
buy_text = texts.get('BUY_TRAFFIC_BUTTON', 'Buy traffic')
|
||||
sub_text = texts.get('MY_SUBSCRIPTION_BUTTON', 'My subscription')
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[build_miniapp_or_callback_button(text=buy_text, callback_data='subscription_add_traffic')],
|
||||
[build_miniapp_or_callback_button(text=sub_text, callback_data='subscription')],
|
||||
]
|
||||
)
|
||||
|
||||
async def _notify_user(
|
||||
self,
|
||||
user: User,
|
||||
text_key: str,
|
||||
*,
|
||||
reply_markup: InlineKeyboardMarkup | None = None,
|
||||
format_kwargs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Send a notification to user via appropriate channel.
|
||||
|
||||
Telegram users receive a bot message; email-only users receive
|
||||
an email and/or WebSocket notification through the unified
|
||||
notification delivery service.
|
||||
"""
|
||||
texts = get_texts(user.language)
|
||||
message = texts.get(text_key)
|
||||
if not message:
|
||||
logger.warning('Missing locale key %s for language %s', text_key, user.language)
|
||||
return
|
||||
|
||||
if format_kwargs:
|
||||
try:
|
||||
message = message.format(**format_kwargs)
|
||||
except (KeyError, IndexError):
|
||||
logger.warning('Failed to format message %s with kwargs %s', text_key, format_kwargs)
|
||||
return
|
||||
|
||||
# Append "Close" button to every webhook notification keyboard
|
||||
close_text = texts.get('WEBHOOK_CLOSE_BUTTON', '✖️ Закрыть')
|
||||
close_row = [InlineKeyboardButton(text=close_text, callback_data='webhook:close')]
|
||||
if reply_markup:
|
||||
reply_markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[*reply_markup.inline_keyboard, close_row],
|
||||
)
|
||||
else:
|
||||
reply_markup = InlineKeyboardMarkup(inline_keyboard=[close_row])
|
||||
|
||||
notification_type = _TEXT_KEY_TO_NOTIFICATION_TYPE.get(text_key)
|
||||
if not notification_type:
|
||||
logger.warning('No NotificationType mapping for text_key %s', text_key)
|
||||
return
|
||||
|
||||
context = {'text_key': text_key, **(format_kwargs or {})}
|
||||
|
||||
try:
|
||||
await notification_delivery_service.send_notification(
|
||||
user=user,
|
||||
notification_type=notification_type,
|
||||
context=context,
|
||||
bot=self.bot,
|
||||
telegram_message=message,
|
||||
telegram_markup=reply_markup,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Notification delivery failed for user %s, text_key %s', user.id, text_key)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Webhook timestamp helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _stamp_webhook_update(subscription: Subscription) -> None:
|
||||
"""Mark subscription as recently updated by webhook to prevent sync overwrite."""
|
||||
subscription.last_webhook_update_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_user_expired(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
await expire_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s expired for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRED', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_user_disabled(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s disabled for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DISABLED', reply_markup=self._get_subscription_keyboard(user))
|
||||
|
||||
async def _handle_user_enabled(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
await reactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s re-enabled for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_ENABLED', reply_markup=self._get_connect_keyboard(user))
|
||||
|
||||
async def _handle_user_limited(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s limited (traffic) for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_LIMITED', reply_markup=self._get_traffic_keyboard(user))
|
||||
|
||||
async def _handle_user_traffic_reset(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
await update_subscription_usage(db, subscription, 0.0)
|
||||
# Re-enable if was disabled due to traffic limit
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
await reactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: traffic reset for subscription %s, user %s', subscription.id, user.id)
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_TRAFFIC_RESET', reply_markup=self._get_subscription_keyboard(user))
|
||||
|
||||
async def _handle_user_modified(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
"""Sync subscription fields from webhook payload without notifying user."""
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
changed = False
|
||||
|
||||
# Sync traffic limit
|
||||
traffic_limit_bytes = data.get('trafficLimitBytes')
|
||||
if traffic_limit_bytes is not None:
|
||||
try:
|
||||
new_limit_gb = int(traffic_limit_bytes) // (1024**3)
|
||||
if subscription.traffic_limit_gb != new_limit_gb:
|
||||
subscription.traffic_limit_gb = new_limit_gb
|
||||
changed = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sync used traffic
|
||||
used_traffic_bytes = data.get('usedTrafficBytes')
|
||||
if used_traffic_bytes is not None:
|
||||
try:
|
||||
new_used_gb = round(int(used_traffic_bytes) / (1024**3), 2)
|
||||
subscription.traffic_used_gb = new_used_gb
|
||||
changed = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sync expire date
|
||||
expire_at = data.get('expireAt')
|
||||
if expire_at:
|
||||
try:
|
||||
parsed_dt = datetime.fromisoformat(expire_at.replace('Z', '+00:00'))
|
||||
new_end_date = parsed_dt.astimezone(UTC).replace(tzinfo=None)
|
||||
if subscription.end_date != new_end_date:
|
||||
subscription.end_date = new_end_date
|
||||
changed = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sync status from panel
|
||||
panel_status = data.get('status')
|
||||
if panel_status:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
end_date = subscription.end_date
|
||||
if panel_status == 'ACTIVE' and end_date and end_date > now:
|
||||
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
changed = True
|
||||
logger.info(
|
||||
'Webhook: subscription %s reactivated (%s → active) for user %s',
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
user.id,
|
||||
)
|
||||
elif panel_status == 'DISABLED':
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
subscription.status = SubscriptionStatus.DISABLED.value
|
||||
changed = True
|
||||
|
||||
# Sync subscription URL (validate to prevent stored XSS)
|
||||
subscription_url = data.get('subscriptionUrl')
|
||||
if (
|
||||
subscription_url
|
||||
and self._is_valid_url(subscription_url)
|
||||
and subscription.subscription_url != subscription_url
|
||||
):
|
||||
subscription.subscription_url = subscription_url
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite, even if no fields changed
|
||||
self._stamp_webhook_update(subscription)
|
||||
if changed:
|
||||
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
logger.info('Webhook: subscription %s modified (synced from panel) for user %s', subscription.id, user.id)
|
||||
await db.commit()
|
||||
|
||||
async def _handle_user_deleted(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
user_id = user.id
|
||||
sub_id = subscription.id if subscription else None
|
||||
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
|
||||
# Decrement server counters BEFORE clearing connected_squads
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
|
||||
# Re-fetch after potential rollback inside decrement_subscription_server_counts
|
||||
try:
|
||||
await db.refresh(subscription)
|
||||
except Exception:
|
||||
# Subscription was cascade-deleted, re-fetch user and skip subscription updates
|
||||
logger.warning(
|
||||
'Webhook: subscription %s already deleted for user %s, skipping subscription cleanup',
|
||||
sub_id,
|
||||
user_id,
|
||||
)
|
||||
subscription = None
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.error('Webhook: user %s not found after rollback', user_id)
|
||||
return
|
||||
|
||||
if subscription:
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
logger.info(
|
||||
'Webhook: subscription %s marked expired (user deleted in panel) for user %s',
|
||||
sub_id,
|
||||
user_id,
|
||||
)
|
||||
|
||||
# Clear subscription data — panel user no longer exists
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = None
|
||||
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
# Remove SubscriptionServer link rows
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
|
||||
|
||||
# Clear remnawave linkage
|
||||
if user.remnawave_uuid:
|
||||
user.remnawave_uuid = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_user_revoked(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
new_url = data.get('subscriptionUrl')
|
||||
new_crypto_link = data.get('subscriptionCryptoLink')
|
||||
changed = False
|
||||
|
||||
if new_url and self._is_valid_url(new_url) and subscription.subscription_url != new_url:
|
||||
subscription.subscription_url = new_url
|
||||
changed = True
|
||||
if (
|
||||
new_crypto_link
|
||||
and self._is_valid_link(new_crypto_link)
|
||||
and subscription.subscription_crypto_link != new_crypto_link
|
||||
):
|
||||
subscription.subscription_crypto_link = new_crypto_link
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite
|
||||
self._stamp_webhook_update(subscription)
|
||||
if changed:
|
||||
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
logger.info(
|
||||
'Webhook: subscription %s credentials revoked/updated for user %s', subscription.id, user.id
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_REVOKED', reply_markup=self._get_connect_keyboard(user))
|
||||
|
||||
async def _handle_user_created(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
logger.info('Webhook: user %s created externally in panel (uuid=%s)', user.id, data.get('uuid'))
|
||||
|
||||
async def _handle_expires_in_72h(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRES_72H', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_expires_in_48h(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRES_48H', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_expires_in_24h(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRES_24H', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_expired_24h_ago(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRED_24H_AGO', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_first_connected(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
logger.info('Webhook: user %s first VPN connection', user.id)
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_FIRST_CONNECTED', reply_markup=self._get_subscription_keyboard(user))
|
||||
|
||||
async def _handle_bandwidth_threshold(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
# Extract threshold percentage from meta or data
|
||||
percent = data.get('thresholdPercent') or data.get('threshold', '')
|
||||
if not percent:
|
||||
# Try to extract from meta
|
||||
meta = data.get('meta', {})
|
||||
if isinstance(meta, dict):
|
||||
percent = meta.get('thresholdPercent', '80')
|
||||
|
||||
# Sanitize to numeric value only (prevent format string injection)
|
||||
percent_str = re.sub(r'[^\d.]', '', str(percent)) or '80'
|
||||
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_SUB_BANDWIDTH_THRESHOLD',
|
||||
reply_markup=self._get_traffic_keyboard(user),
|
||||
format_kwargs={'percent': percent_str},
|
||||
)
|
||||
|
||||
async def _handle_user_not_connected(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
logger.info('Webhook: user %s has not connected to VPN', user.id)
|
||||
await self._notify_user(user, 'WEBHOOK_USER_NOT_CONNECTED', reply_markup=self._get_connect_keyboard(user))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Device event handlers (user_hwid_devices scope)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_device_name(data: dict) -> str:
|
||||
"""Extract device name from webhook payload.
|
||||
|
||||
RemnaWave sends device info in data['hwidUserDevice'] nested object.
|
||||
Builds a composite name: "tag (platform)" or just "platform" or hwid short.
|
||||
"""
|
||||
device_obj = data.get('hwidUserDevice')
|
||||
if not isinstance(device_obj, dict):
|
||||
# Fallback: top-level fields
|
||||
raw = data.get('deviceName') or data.get('tag') or data.get('hwid') or ''
|
||||
return html.escape(str(raw)) if raw else ''
|
||||
|
||||
tag = (device_obj.get('tag') or device_obj.get('deviceName') or device_obj.get('name') or '').strip()
|
||||
platform = (device_obj.get('platform') or '').strip()
|
||||
hwid = (device_obj.get('hwid') or '').strip()
|
||||
|
||||
if tag and platform:
|
||||
return html.escape(f'{tag} ({platform})')
|
||||
if tag:
|
||||
return html.escape(tag)
|
||||
if platform and hwid:
|
||||
# Show platform + short hwid suffix for identification
|
||||
hwid_short = hwid[:8] if len(hwid) > 8 else hwid
|
||||
return html.escape(f'{platform} ({hwid_short})')
|
||||
if platform:
|
||||
return html.escape(platform)
|
||||
if hwid:
|
||||
hwid_short = hwid[:12] if len(hwid) > 12 else hwid
|
||||
return html.escape(hwid_short)
|
||||
return ''
|
||||
|
||||
async def _handle_device_added(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
device_name = self._extract_device_name(data)
|
||||
logger.info('Webhook: device added for user %s: %s', user.id, device_name or '(empty)')
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_DEVICE_ADDED',
|
||||
reply_markup=self._get_subscription_keyboard(user),
|
||||
format_kwargs={'device': device_name or '—'},
|
||||
)
|
||||
|
||||
async def _handle_device_deleted(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
device_name = self._extract_device_name(data)
|
||||
logger.info('Webhook: device deleted for user %s: %s', user.id, device_name or '(empty)')
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_DEVICE_DELETED',
|
||||
reply_markup=self._get_subscription_keyboard(user),
|
||||
format_kwargs={'device': device_name or '—'},
|
||||
)
|
||||
@@ -336,11 +336,6 @@ class SubscriptionRenewalService:
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
devices_limit = max(1, devices_limit - 1)
|
||||
|
||||
total_cost, details = await calculate_subscription_total_cost(
|
||||
db,
|
||||
period_days,
|
||||
@@ -454,7 +449,30 @@ class SubscriptionRenewalService:
|
||||
subscription_before = subscription
|
||||
old_end_date = subscription_before.end_date
|
||||
|
||||
subscription_after = await extend_subscription(db, subscription_before, period_days)
|
||||
try:
|
||||
subscription_after = await extend_subscription(db, subscription_before, period_days)
|
||||
except Exception:
|
||||
# Compensate: refund the charged balance since extension failed
|
||||
if charge_from_balance > 0:
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
charge_from_balance,
|
||||
'Возврат: ошибка продления подписки',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Failed to refund %s kopeks to user %s after extension failure: %s',
|
||||
charge_from_balance,
|
||||
user.id,
|
||||
refund_error,
|
||||
)
|
||||
raise
|
||||
|
||||
server_ids = pricing.server_ids or []
|
||||
server_prices_for_period = pricing.details.get('servers_individual_prices', [])
|
||||
|
||||
@@ -790,11 +790,6 @@ class SubscriptionService:
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
|
||||
@@ -1105,6 +1105,9 @@ class UserService:
|
||||
try:
|
||||
if user.subscription:
|
||||
logger.info(f'🔄 Удаляем подписку {user.subscription.id}')
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
|
||||
)
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -47,6 +49,24 @@ class WataService:
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
_MAX_RETRIES = 2
|
||||
|
||||
@staticmethod
|
||||
def _parse_retry_after(response: aiohttp.ClientResponse, response_text: str) -> float:
|
||||
"""Extract retry delay from Retry-After header or response body."""
|
||||
retry_after = response.headers.get('Retry-After')
|
||||
if retry_after:
|
||||
try:
|
||||
return float(retry_after)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
match = re.search(r'[Rr]etry after (\d+)', response_text)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
|
||||
return 45.0
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -61,35 +81,57 @@ class WataService:
|
||||
url = self._build_url(path)
|
||||
timeout = aiohttp.ClientTimeout(total=self.request_timeout)
|
||||
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(timeout=timeout) as session,
|
||||
session.request(
|
||||
method,
|
||||
url,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self._build_headers(),
|
||||
) as response,
|
||||
):
|
||||
response_text = await response.text()
|
||||
if response.status >= 400:
|
||||
logger.error('WATA API error %s: %s', response.status, response_text)
|
||||
raise WataAPIError(f'WATA API returned status {response.status}: {response_text}')
|
||||
last_error: WataAPIError | None = None
|
||||
for attempt in range(1 + self._MAX_RETRIES):
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(timeout=timeout) as session,
|
||||
session.request(
|
||||
method,
|
||||
url,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self._build_headers(),
|
||||
) as response,
|
||||
):
|
||||
response_text = await response.text()
|
||||
|
||||
if not response_text:
|
||||
return {}
|
||||
if response.status == 429:
|
||||
retry_delay = self._parse_retry_after(response, response_text)
|
||||
if attempt < self._MAX_RETRIES:
|
||||
logger.warning(
|
||||
'WATA API 429 on %s %s, retry %d/%d after %.0fs',
|
||||
method,
|
||||
path,
|
||||
attempt + 1,
|
||||
self._MAX_RETRIES,
|
||||
retry_delay,
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
logger.warning('WATA API 429 on %s %s, retries exhausted', method, path)
|
||||
last_error = WataAPIError(f'WATA API rate limited on {method} {path}')
|
||||
break
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as error:
|
||||
logger.error('WATA API returned non-JSON response: %s', error)
|
||||
raise WataAPIError('WATA API returned invalid JSON') from error
|
||||
if response.status >= 400:
|
||||
logger.error('WATA API error %s: %s', response.status, response_text)
|
||||
raise WataAPIError(f'WATA API returned status {response.status}: {response_text}')
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error('Error communicating with WATA API: %s', error)
|
||||
raise WataAPIError('Failed to communicate with WATA API') from error
|
||||
if not response_text:
|
||||
return {}
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as error:
|
||||
logger.error('WATA API returned non-JSON response: %s', error)
|
||||
raise WataAPIError('WATA API returned invalid JSON') from error
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error('Error communicating with WATA API: %s', error)
|
||||
raise WataAPIError('Failed to communicate with WATA API') from error
|
||||
|
||||
raise last_error or WataAPIError('WATA API request failed')
|
||||
|
||||
@staticmethod
|
||||
def _amount_from_kopeks(amount_kopeks: int) -> float:
|
||||
|
||||
@@ -173,75 +173,3 @@ def rate_limit(rate: float = 1.0, key: str = None):
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def modem_available(for_enable: bool = False, for_disable: bool = False):
|
||||
"""
|
||||
Декоратор для проверки доступности модема.
|
||||
|
||||
Проверяет:
|
||||
- Наличие подписки
|
||||
- Подписка не триальная
|
||||
- Функция модема включена в настройках
|
||||
- (опционально) Модем ещё не подключен (for_enable=True)
|
||||
- (опционально) Модем уже подключен (for_disable=True)
|
||||
|
||||
Args:
|
||||
for_enable: Проверять, что модем ещё не подключен
|
||||
for_disable: Проверять, что модем подключен
|
||||
|
||||
Usage:
|
||||
@modem_available()
|
||||
async def handle_modem_menu(callback, db_user, db): ...
|
||||
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_enable(callback, db_user, db): ...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(event: types.Update, *args, **kwargs) -> Any:
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
if not db_user:
|
||||
logger.warning('modem_available: нет db_user в kwargs')
|
||||
return None
|
||||
|
||||
from app.services.modem_service import ModemError, get_modem_service
|
||||
|
||||
service = get_modem_service()
|
||||
result = service.check_availability(db_user, for_enable=for_enable, for_disable=for_disable)
|
||||
|
||||
if not result.available:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
error_messages = {
|
||||
ModemError.NO_SUBSCRIPTION: texts.t(
|
||||
'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
|
||||
),
|
||||
ModemError.TRIAL_SUBSCRIPTION: texts.t(
|
||||
'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
|
||||
),
|
||||
ModemError.MODEM_DISABLED: texts.t('MODEM_DISABLED', 'Функция модема отключена'),
|
||||
ModemError.ALREADY_ENABLED: texts.t('MODEM_ALREADY_ENABLED', 'Модем уже подключен'),
|
||||
ModemError.NOT_ENABLED: texts.t('MODEM_NOT_ENABLED', 'Модем не подключен'),
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result.error, texts.ERROR)
|
||||
|
||||
try:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(error_text, show_alert=True)
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(error_text)
|
||||
except TelegramBadRequest as e:
|
||||
if 'query is too old' not in str(e).lower():
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -4,32 +4,28 @@ from aiogram.types import InlineKeyboardButton
|
||||
from app.config import settings
|
||||
|
||||
|
||||
DEFAULT_UNAVAILABLE_CALLBACK = 'menu_profile_unavailable'
|
||||
|
||||
|
||||
def build_miniapp_or_callback_button(
|
||||
text: str,
|
||||
*,
|
||||
callback_data: str,
|
||||
unavailable_callback: str = DEFAULT_UNAVAILABLE_CALLBACK,
|
||||
) -> InlineKeyboardButton:
|
||||
"""Create a button that opens the miniapp in text menu mode.
|
||||
"""Create a button that opens the miniapp or falls back to a callback.
|
||||
|
||||
When the simplified text menu mode is enabled we should avoid exposing
|
||||
deep bot flows and redirect the user to the configured miniapp instead.
|
||||
If the miniapp URL is missing we fall back to a safe callback that shows
|
||||
an alert about the unavailable profile rather than opening disabled
|
||||
sections of the bot.
|
||||
In text menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button
|
||||
opens the full cabinet miniapp. Otherwise (or outside text menu mode)
|
||||
the regular ``callback_data`` is used so the user stays in the bot.
|
||||
|
||||
Only ``MINIAPP_CUSTOM_URL`` is considered here — the purchase-only URL
|
||||
(``MINIAPP_PURCHASE_URL``) is intentionally excluded because it cannot
|
||||
display subscription details and would load indefinitely.
|
||||
"""
|
||||
|
||||
if settings.is_text_main_menu_mode():
|
||||
miniapp_url = settings.get_main_menu_miniapp_url()
|
||||
miniapp_url = (settings.MINIAPP_CUSTOM_URL or '').strip()
|
||||
if miniapp_url:
|
||||
return InlineKeyboardButton(
|
||||
text=text,
|
||||
web_app=types.WebAppInfo(url=miniapp_url),
|
||||
)
|
||||
safe_callback = unavailable_callback or DEFAULT_UNAVAILABLE_CALLBACK
|
||||
return InlineKeyboardButton(text=text, callback_data=safe_callback)
|
||||
|
||||
return InlineKeyboardButton(text=text, callback_data=callback_data)
|
||||
|
||||
@@ -129,14 +129,6 @@ async def compute_simple_subscription_price(
|
||||
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_original = additional_devices * settings.PRICE_PER_DEVICE
|
||||
|
||||
# Расчёт цены модема (если включён)
|
||||
modem_enabled = params.get('modem_enabled', False)
|
||||
modem_price_original = 0
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
months = calculate_months_from_days(period_days)
|
||||
modem_price_original = modem_price_per_month * months
|
||||
|
||||
promo_group: PromoGroup | None = params.get('promo_group')
|
||||
|
||||
if promo_group is None:
|
||||
@@ -256,11 +248,7 @@ async def compute_simple_subscription_price(
|
||||
)
|
||||
|
||||
total_before_discount = (
|
||||
base_price_original
|
||||
+ traffic_price_original
|
||||
+ devices_price_original
|
||||
+ servers_price_original
|
||||
+ modem_price_original
|
||||
base_price_original + traffic_price_original + devices_price_original + servers_price_original
|
||||
)
|
||||
|
||||
total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total
|
||||
@@ -274,8 +262,6 @@ async def compute_simple_subscription_price(
|
||||
'traffic_discount': traffic_discount,
|
||||
'devices_price': devices_price_original,
|
||||
'devices_discount': devices_discount,
|
||||
'modem_price': modem_price_original,
|
||||
'modem_enabled': modem_enabled,
|
||||
'servers_price': servers_price_original,
|
||||
'servers_discount': servers_discount_total,
|
||||
'servers_final': sum(item['final_price'] for item in server_breakdown),
|
||||
|
||||
@@ -5926,7 +5926,10 @@ async def update_subscription_servers_endpoint(
|
||||
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
try:
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления счётчика серверов (add): {e}')
|
||||
|
||||
removed_server_ids = [
|
||||
catalog[uuid].get('server_id') for uuid in removed if catalog[uuid].get('server_id') is not None
|
||||
@@ -5934,7 +5937,10 @@ async def update_subscription_servers_endpoint(
|
||||
|
||||
if removed_server_ids:
|
||||
await remove_subscription_servers(db, subscription.id, removed_server_ids)
|
||||
await remove_user_from_servers(db, removed_server_ids)
|
||||
try:
|
||||
await remove_user_from_servers(db, removed_server_ids)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления счётчика серверов (remove): {e}')
|
||||
|
||||
ordered_selection = []
|
||||
seen_selection = set()
|
||||
|
||||
@@ -30,7 +30,6 @@ from ..schemas.subscriptions import (
|
||||
SubscriptionCreateRequest,
|
||||
SubscriptionDevicesRequest,
|
||||
SubscriptionExtendRequest,
|
||||
SubscriptionModemRequest,
|
||||
SubscriptionResponse,
|
||||
SubscriptionSquadRequest,
|
||||
SubscriptionTrafficRequest,
|
||||
@@ -54,7 +53,6 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
@@ -323,41 +321,3 @@ async def delete_subscription(
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post('/{subscription_id}/modem', response_model=SubscriptionResponse)
|
||||
async def set_subscription_modem(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionModemRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
"""Включить или выключить модем для подписки."""
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem is not available for trial subscriptions')
|
||||
|
||||
if not settings.is_modem_enabled():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem feature is disabled')
|
||||
|
||||
current_modem = getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
if payload.enabled == current_modem:
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
if payload.enabled:
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
else:
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
@@ -70,7 +70,6 @@ def _serialize_subscription(subscription: Subscription | None) -> SubscriptionSu
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
|
||||
@@ -16,7 +16,6 @@ class SubscriptionResponse(BaseModel):
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
modem_enabled: bool = False
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int | None = None
|
||||
subscription_url: str | None = None
|
||||
@@ -51,7 +50,3 @@ class SubscriptionDevicesRequest(BaseModel):
|
||||
|
||||
class SubscriptionSquadRequest(BaseModel):
|
||||
squad_uuid: str
|
||||
|
||||
|
||||
class SubscriptionModemRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
@@ -24,7 +24,6 @@ class SubscriptionSummary(BaseModel):
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
modem_enabled: bool = False
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int | None = None
|
||||
subscription_url: str | None = None
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
FastAPI router for receiving incoming webhooks from RemnaWave backend.
|
||||
|
||||
Handles HMAC-SHA256 signature verification, payload parsing, and
|
||||
event dispatch to RemnaWaveWebhookService.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiogram import Bot
|
||||
from fastapi import APIRouter, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Max accepted webhook payload size (64 KB) to prevent memory exhaustion DoS
|
||||
_MAX_BODY_SIZE = 64 * 1024
|
||||
|
||||
|
||||
def _verify_signature(raw_body: bytes, received_signature: str, secret: str) -> bool:
|
||||
"""Verify HMAC-SHA256 signature from RemnaWave backend."""
|
||||
expected = hmac.new(secret.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, received_signature)
|
||||
|
||||
|
||||
def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
webhook_service = RemnaWaveWebhookService(bot)
|
||||
webhook_path = settings.REMNAWAVE_WEBHOOK_PATH
|
||||
|
||||
@router.get(webhook_path)
|
||||
async def remnawave_webhook_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'remnawave_webhook',
|
||||
'enabled': settings.is_remnawave_webhook_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(webhook_path)
|
||||
async def remnawave_webhook(request: Request) -> JSONResponse:
|
||||
raw_body = await request.body()
|
||||
if not raw_body:
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'empty_body'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if len(raw_body) > _MAX_BODY_SIZE:
|
||||
logger.warning('RemnaWave webhook: payload too large (%d bytes)', len(raw_body))
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'payload_too_large'},
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
)
|
||||
|
||||
# Verify HMAC-SHA256 signature (always required)
|
||||
secret = settings.REMNAWAVE_WEBHOOK_SECRET
|
||||
if not secret:
|
||||
logger.error('RemnaWave webhook: secret not configured, rejecting request')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'webhook_not_configured'},
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
signature = request.headers.get('X-Remnawave-Signature') or ''
|
||||
if not signature:
|
||||
logger.warning('RemnaWave webhook: missing signature header')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'missing_signature'},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not _verify_signature(raw_body, signature, secret):
|
||||
logger.warning('RemnaWave webhook: invalid signature')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'invalid_signature'},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Parse JSON payload
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'invalid_json'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Extract and validate event info
|
||||
scope = payload.get('scope', '')
|
||||
event = payload.get('event', '')
|
||||
data = payload.get('data')
|
||||
|
||||
if not scope or not event:
|
||||
logger.warning('RemnaWave webhook: missing scope or event')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'missing_scope_or_event'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
# RemnaWave sends event as full qualified name (e.g. "user.modified"),
|
||||
# so we use event directly instead of concatenating scope + event.
|
||||
event_name = event
|
||||
logger.info('RemnaWave webhook received: scope=%s, event=%s', scope, event_name)
|
||||
|
||||
# Process event — return 200 to prevent retries for application-level errors.
|
||||
# Only return non-200 for infrastructure failures (DB unavailable).
|
||||
# Admin events (node/service/crm) don't need a DB session.
|
||||
if webhook_service.is_admin_event(event_name):
|
||||
try:
|
||||
processed = await webhook_service.process_event(None, event_name, data)
|
||||
return JSONResponse({'status': 'ok', 'processed': processed})
|
||||
except Exception:
|
||||
logger.exception('RemnaWave webhook processing error for event %s', event_name)
|
||||
return JSONResponse({'status': 'ok', 'processed': False})
|
||||
|
||||
# User events require a DB session
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
processed = await webhook_service.process_event(db, event_name, data)
|
||||
await db.commit()
|
||||
return JSONResponse({'status': 'ok', 'processed': processed})
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception('RemnaWave webhook processing error for event %s', event_name)
|
||||
return JSONResponse({'status': 'ok', 'processed': False})
|
||||
except Exception:
|
||||
logger.error('RemnaWave webhook: failed to get database session')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'database_unavailable'},
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -111,6 +111,16 @@ def create_unified_app(
|
||||
payments_router = payments.create_payment_router(bot, payment_service)
|
||||
if payments_router:
|
||||
app.include_router(payments_router)
|
||||
|
||||
# Mount RemnaWave incoming webhook router
|
||||
remnawave_webhook_enabled = settings.is_remnawave_webhook_enabled()
|
||||
if remnawave_webhook_enabled:
|
||||
from app.webserver.remnawave_webhook import create_remnawave_webhook_router
|
||||
|
||||
remnawave_router = create_remnawave_webhook_router(bot)
|
||||
app.include_router(remnawave_router)
|
||||
logger.info('RemnaWave webhook router mounted at %s', settings.REMNAWAVE_WEBHOOK_PATH)
|
||||
|
||||
payment_providers_state = {
|
||||
'tribute': settings.TRIBUTE_ENABLED,
|
||||
'mulenpay': settings.is_mulenpay_enabled(),
|
||||
@@ -181,6 +191,11 @@ def create_unified_app(
|
||||
'path': str(miniapp_path),
|
||||
}
|
||||
|
||||
remnawave_webhook_state = {
|
||||
'enabled': remnawave_webhook_enabled,
|
||||
'path': settings.REMNAWAVE_WEBHOOK_PATH if remnawave_webhook_enabled else None,
|
||||
}
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
@@ -188,6 +203,7 @@ def create_unified_app(
|
||||
'web_api_enabled': settings.is_web_api_enabled(),
|
||||
'payment_webhooks': payment_state,
|
||||
'telegram_webhook': telegram_state,
|
||||
'remnawave_webhook': remnawave_webhook_state,
|
||||
'miniapp_static': miniapp_state,
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.9.0"
|
||||
version = "3.10.2"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
+12
-3
@@ -111,10 +111,19 @@ async def _post_webhook(client: TestClient, payload: dict, **headers: str) -> we
|
||||
|
||||
|
||||
def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_get_db():
|
||||
yield DummyDB()
|
||||
"""Mock AsyncSessionLocal used by the webhook handler."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
monkeypatch.setattr('app.external.yookassa_webhook.get_db', fake_get_db)
|
||||
mock_session = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.rollback = AsyncMock()
|
||||
mock_session.execute = AsyncMock()
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
monkeypatch.setattr('app.external.yookassa_webhook.AsyncSessionLocal', lambda: ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
"""
|
||||
Тесты для ModemService - управление модемом в подписке.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.services.modem_service import (
|
||||
ModemError,
|
||||
ModemService,
|
||||
get_modem_service,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_settings():
|
||||
"""Создаёт мок настроек приложения."""
|
||||
settings = MagicMock()
|
||||
settings.is_modem_enabled.return_value = True
|
||||
settings.get_modem_price_per_month.return_value = 10000 # 100 рублей
|
||||
settings.get_modem_period_discount.return_value = 0
|
||||
return settings
|
||||
|
||||
|
||||
def create_sample_user():
|
||||
"""Создаёт пример пользователя."""
|
||||
user = SimpleNamespace(
|
||||
id=1,
|
||||
telegram_id=123456789,
|
||||
balance_kopeks=50000, # 500 рублей
|
||||
language='ru',
|
||||
subscription=None,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def create_sample_subscription():
|
||||
"""Создаёт пример подписки."""
|
||||
subscription = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=1,
|
||||
is_trial=False,
|
||||
modem_enabled=False,
|
||||
device_limit=2,
|
||||
end_date=datetime.utcnow() + timedelta(days=30),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
def create_trial_subscription():
|
||||
"""Создаёт триальную подписку."""
|
||||
subscription = SimpleNamespace(
|
||||
id=2,
|
||||
user_id=1,
|
||||
is_trial=True,
|
||||
modem_enabled=False,
|
||||
device_limit=1,
|
||||
end_date=datetime.utcnow() + timedelta(days=7),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
def create_modem_service(monkeypatch):
|
||||
"""Создаёт ModemService с замоканными настройками."""
|
||||
mock_settings = create_mock_settings()
|
||||
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
|
||||
return ModemService(), mock_settings
|
||||
|
||||
|
||||
class TestModemServiceAvailability:
|
||||
"""Тесты проверки доступности модема."""
|
||||
|
||||
def test_check_availability_no_subscription(self, monkeypatch):
|
||||
"""Модем недоступен без подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.subscription = None
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.NO_SUBSCRIPTION
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_trial_subscription(self, monkeypatch):
|
||||
"""Модем недоступен для триальной подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
trial_subscription = create_trial_subscription()
|
||||
sample_user.subscription = trial_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.TRIAL_SUBSCRIPTION
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_modem_disabled_in_settings(self, monkeypatch):
|
||||
"""Модем недоступен, если отключён в настройках."""
|
||||
modem_service, mock_settings = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
mock_settings.is_modem_enabled.return_value = False
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.MODEM_DISABLED
|
||||
|
||||
def test_check_availability_success(self, monkeypatch):
|
||||
"""Модем доступен для платной подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert result.available
|
||||
assert result.error is None
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_for_enable_already_enabled(self, monkeypatch):
|
||||
"""Нельзя подключить уже подключенный модем."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user, for_enable=True)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.ALREADY_ENABLED
|
||||
assert result.modem_enabled
|
||||
|
||||
def test_check_availability_for_disable_not_enabled(self, monkeypatch):
|
||||
"""Нельзя отключить неподключенный модем."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = False
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user, for_disable=True)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.NOT_ENABLED
|
||||
assert not result.modem_enabled
|
||||
|
||||
|
||||
class TestModemServicePricing:
|
||||
"""Тесты расчёта цены модема."""
|
||||
|
||||
def test_calculate_price_one_month(self, monkeypatch):
|
||||
"""Расчёт цены на 1 месяц."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 10000
|
||||
assert result.final_price == 10000
|
||||
assert result.charged_months == 1
|
||||
assert result.discount_percent == 0
|
||||
assert not result.has_discount
|
||||
|
||||
def test_calculate_price_three_months(self, monkeypatch):
|
||||
"""Расчёт цены на 3 месяца."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 30000 # 3 * 10000
|
||||
assert result.charged_months == 3
|
||||
|
||||
def test_calculate_price_with_discount(self, monkeypatch):
|
||||
"""Расчёт цены со скидкой."""
|
||||
modem_service, mock_settings = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
|
||||
mock_settings.get_modem_period_discount.return_value = 10 # 10% скидка
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 30000
|
||||
assert result.discount_percent == 10
|
||||
assert result.discount_amount == 3000
|
||||
assert result.final_price == 27000
|
||||
assert result.has_discount
|
||||
|
||||
|
||||
class TestModemServiceBalance:
|
||||
"""Тесты проверки баланса."""
|
||||
|
||||
def test_check_balance_sufficient(self, monkeypatch):
|
||||
"""Баланса достаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 10000)
|
||||
|
||||
assert has_funds
|
||||
assert missing == 0
|
||||
|
||||
def test_check_balance_insufficient(self, monkeypatch):
|
||||
"""Баланса недостаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 5000
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 10000)
|
||||
|
||||
assert not has_funds
|
||||
assert missing == 5000
|
||||
|
||||
def test_check_balance_zero_price(self, monkeypatch):
|
||||
"""Нулевая цена - всегда достаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 0
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 0)
|
||||
|
||||
assert has_funds
|
||||
assert missing == 0
|
||||
|
||||
|
||||
class TestModemServicePeriodWarning:
|
||||
"""Тесты предупреждений о сроке действия."""
|
||||
|
||||
def test_warning_critical(self, monkeypatch):
|
||||
"""Критическое предупреждение при <= 7 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(7) == 'critical'
|
||||
assert modem_service.get_period_warning_level(5) == 'critical'
|
||||
assert modem_service.get_period_warning_level(1) == 'critical'
|
||||
|
||||
def test_warning_info(self, monkeypatch):
|
||||
"""Информационное предупреждение при <= 30 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(30) == 'info'
|
||||
assert modem_service.get_period_warning_level(15) == 'info'
|
||||
assert modem_service.get_period_warning_level(8) == 'info'
|
||||
|
||||
def test_warning_none(self, monkeypatch):
|
||||
"""Нет предупреждения при > 30 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(31) is None
|
||||
assert modem_service.get_period_warning_level(60) is None
|
||||
assert modem_service.get_period_warning_level(90) is None
|
||||
|
||||
|
||||
class TestModemServiceEnable:
|
||||
"""Тесты подключения модема."""
|
||||
|
||||
async def test_enable_modem_success(self, monkeypatch):
|
||||
"""Успешное подключение модема."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_subtract = AsyncMock(return_value=True)
|
||||
mock_create_transaction = AsyncMock()
|
||||
mock_update_remnawave = AsyncMock()
|
||||
|
||||
monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
|
||||
monkeypatch.setattr('app.services.modem_service.create_transaction', mock_create_transaction)
|
||||
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
|
||||
|
||||
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert result.success
|
||||
assert result.error is None
|
||||
assert result.charged_amount == 10000
|
||||
assert sample_subscription.modem_enabled is True
|
||||
assert sample_subscription.device_limit == 3 # было 2, стало 3
|
||||
|
||||
async def test_enable_modem_insufficient_funds(self, monkeypatch):
|
||||
"""Недостаточно средств для подключения."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 1000 # недостаточно
|
||||
|
||||
mock_db = AsyncMock()
|
||||
|
||||
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert not result.success
|
||||
assert result.error == ModemError.INSUFFICIENT_FUNDS
|
||||
|
||||
async def test_enable_modem_charge_error(self, monkeypatch):
|
||||
"""Ошибка списания средств."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_subtract = AsyncMock(return_value=False) # ошибка списания
|
||||
|
||||
monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
|
||||
|
||||
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert not result.success
|
||||
assert result.error == ModemError.CHARGE_ERROR
|
||||
|
||||
|
||||
class TestModemServiceDisable:
|
||||
"""Тесты отключения модема."""
|
||||
|
||||
async def test_disable_modem_success(self, monkeypatch):
|
||||
"""Успешное отключение модема."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
sample_subscription.device_limit = 3
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_update_remnawave = AsyncMock()
|
||||
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
|
||||
|
||||
result = await modem_service.disable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert result.success
|
||||
assert result.error is None
|
||||
assert sample_subscription.modem_enabled is False
|
||||
assert sample_subscription.device_limit == 2 # было 3, стало 2
|
||||
|
||||
|
||||
class TestModemServiceSingleton:
|
||||
"""Тесты singleton паттерна."""
|
||||
|
||||
def test_get_modem_service_returns_same_instance(self, monkeypatch):
|
||||
"""get_modem_service возвращает один и тот же экземпляр."""
|
||||
# Сбрасываем глобальный экземпляр
|
||||
import app.services.modem_service as modem_module
|
||||
|
||||
modem_module._modem_service = None
|
||||
|
||||
mock_settings = create_mock_settings()
|
||||
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
|
||||
|
||||
service1 = get_modem_service()
|
||||
service2 = get_modem_service()
|
||||
|
||||
assert service1 is service2
|
||||
|
||||
|
||||
class TestModemEnabledGetter:
|
||||
"""Тесты безопасного получения статуса модема."""
|
||||
|
||||
def test_get_modem_enabled_true(self, monkeypatch):
|
||||
"""Модем включён."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
|
||||
assert modem_service.get_modem_enabled(sample_subscription) is True
|
||||
|
||||
def test_get_modem_enabled_false(self, monkeypatch):
|
||||
"""Модем выключен."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = False
|
||||
|
||||
assert modem_service.get_modem_enabled(sample_subscription) is False
|
||||
|
||||
def test_get_modem_enabled_none_subscription(self, monkeypatch):
|
||||
"""Подписка None."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_modem_enabled(None) is False
|
||||
|
||||
def test_get_modem_enabled_no_attribute(self, monkeypatch):
|
||||
"""У подписки нет атрибута modem_enabled."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
subscription = SimpleNamespace(id=1) # без modem_enabled
|
||||
|
||||
assert modem_service.get_modem_enabled(subscription) is False
|
||||
Reference in New Issue
Block a user