Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c05b36b9d | |||
| b398eecbeb | |||
| 5ab80b09a1 | |||
| 535b92d43b | |||
| cbaddd65e0 | |||
| 136cae68f1 | |||
| 38abf49af9 | |||
| 799243a988 | |||
| 6d024716c7 | |||
| 0892f494d9 | |||
| 42b67bdc47 | |||
| 6c41263511 | |||
| 1aab9550ce | |||
| d596b19d96 | |||
| 0863e3023d | |||
| 1ef2e1264f | |||
| c40806a5a3 | |||
| 841c288313 | |||
| cd1a4a4a6e | |||
| e9e61f4892 | |||
| 167cddcd2f | |||
| 5b4c597e9b | |||
| 0cb1c9b580 | |||
| ff4471a22a | |||
| 93d7bd74a3 | |||
| c770948c1f | |||
| 2e1a6faec1 | |||
| 0031c9e2e0 | |||
| 637d2a07e0 | |||
| 988ffbebdb | |||
| 5a795a2ae2 | |||
| 9b6cd74dbf | |||
| daa2f13ca3 | |||
| 386b9ae998 | |||
| e64854dc48 | |||
| 23eed94009 | |||
| f04ffa58e4 | |||
| fc45db6f3c | |||
| b8874772be | |||
| d8c7793a26 | |||
| 10d08d5b40 | |||
| b07a889826 | |||
| a24b4c72e9 | |||
| 5e1d00ad0d | |||
| d33ebb1453 | |||
| 7a2bcf3d2f | |||
| dbb68582e4 | |||
| 479b9bc384 | |||
| 73f3987481 | |||
| 8b38f1e37d | |||
| 6976129972 | |||
| 207e4673c0 | |||
| 160ff7ff9f | |||
| 6c7c57138d | |||
| 12b5a39194 | |||
| 7678150e6a | |||
| 99e35cae4c | |||
| af313a12ed | |||
| 71d18287fe | |||
| 6b24b69b53 | |||
| 9528457b89 | |||
| 69322640c5 | |||
| 3c0703b599 | |||
| d14aa5bd8a | |||
| baa5da243e | |||
| fff01d1ce3 | |||
| 52c8442423 | |||
| 150f9e741a | |||
| 4ae2234b7f | |||
| 71366a8133 | |||
| fb6cda2c63 | |||
| 2d4f9da9a7 | |||
| 7a39abe05c | |||
| 79659ec5fe | |||
| a3457d5853 | |||
| 0392aa5b45 | |||
| 2c68d9ded9 | |||
| a437ebd65b | |||
| e2323755de | |||
| 5e23920723 | |||
| 5e45fc1285 | |||
| ff1556ffcd | |||
| 549e8fa332 | |||
| 8a7fb598fa | |||
| 521c364fbb | |||
| db60f5c5ba | |||
| affc07985e | |||
| 2761255e65 | |||
| 53988c319b | |||
| 777ea3b99b | |||
| 672ce18f38 | |||
| aafcec2716 | |||
| 4e5b607288 | |||
| aae85683a0 | |||
| 9971c43218 | |||
| b4370e9a34 | |||
| 101e27acd6 | |||
| 5b9002896e | |||
| e8ac14b0b7 | |||
| 7cd6c3acb7 | |||
| 7e7795fc4e | |||
| 1f75413abd | |||
| 3c6d674ce5 | |||
| 92efc52f7e | |||
| c558f69d62 | |||
| 168cb5ea38 | |||
| 6a48cc687c | |||
| 9a5b0553c6 | |||
| 7515964599 | |||
| bf5e0ecd4f | |||
| 6d058beade | |||
| 91f557e357 | |||
| 8e6c594a7b | |||
| 94cc04703b | |||
| 9ac621cebd | |||
| 5d9f6064d6 | |||
| 010be14379 | |||
| d864c80457 | |||
| ac04284d85 | |||
| e1377ad7a0 | |||
| 6c7bbba7ed | |||
| 7a70a90d63 | |||
| 58187cecdb | |||
| 649a358560 | |||
| e0f1f221df | |||
| 1342236b23 | |||
| 61681b393e | |||
| 20a36fe869 | |||
| a4fe68bf2a | |||
| 827cdd7805 | |||
| 2479167073 | |||
| 5cc8f7869f | |||
| 330ace039b | |||
| 36ad95dfef | |||
| a7d564b7d1 | |||
| 4582daa1d1 | |||
| c440274c32 | |||
| a02b2f163b | |||
| f5a6dbdeec | |||
| 282bf3aa11 | |||
| d302e02b0a | |||
| da554736ba | |||
| aefbc21033 | |||
| add0fb4fbe | |||
| f1bec83a6f | |||
| a9e15a7cc1 | |||
| 53c0f17200 | |||
| 0c5a28d1c3 | |||
| cedb6b4cc4 | |||
| 5c7ec8c4a2 | |||
| 3bb2a055e7 | |||
| b117df126a | |||
| 610a39e322 | |||
| 71fca0ccf8 | |||
| 2568cf086a | |||
| ae453279bf | |||
| bdb04ab91b | |||
| 3636cd27b6 | |||
| 04279542c5 | |||
| c7666274b8 | |||
| 5b27b8834d | |||
| 4b506cde0c | |||
| 121d254fc0 | |||
| 515c9dc8b1 | |||
| 4e03fa1b71 | |||
| 80752c3004 | |||
| a11d37ef96 | |||
| 14c89c7aab | |||
| b09b47fa49 | |||
| 49b8a96a84 | |||
| 6f800ac60b | |||
| 46dfb59f3d | |||
| d5bbb9f14a | |||
| 7c52811750 | |||
| 2d4b46afbd | |||
| 83093236fc | |||
| 3881e7d1d5 | |||
| 1c3dbd57c5 | |||
| 6f1275f291 | |||
| cfc48ea061 | |||
| 2679172ae4 | |||
| 6a0c276454 | |||
| 5872e3bd3c | |||
| f6041ab055 | |||
| b5c5da552d | |||
| c093ef95ed | |||
| d376a25e64 | |||
| 4ec926d072 | |||
| 65a7df4549 | |||
| 57c252dcbf | |||
| 9b74b40794 | |||
| 0b028e1f13 | |||
| 337b948fda | |||
| 5de187c6cd | |||
| 7150204c71 | |||
| 3d44403318 | |||
| 812f3e983c | |||
| 7549e571fe | |||
| 9d3f096be0 | |||
| fd40cd3ba6 | |||
| 1ba566f30f | |||
| 9b9d17b8e7 | |||
| 3fee600db0 | |||
| fbb1091f8b | |||
| 8513d67a78 | |||
| 45ce615fbe | |||
| 2ab4ca5b09 | |||
| b229dc22ba | |||
| bf49a5c1d5 | |||
| 27b6e974ab | |||
| 9f00b56be0 | |||
| 05177bff92 | |||
| f40f233562 | |||
| d3a351aeba | |||
| fb76d30457 | |||
| afba2137d1 | |||
| 9ef3ca1831 | |||
| 65ae2f5c48 | |||
| 2686dcd30a | |||
| 6bc125a2f4 | |||
| 29c6e296df | |||
| 3d1d064af7 | |||
| ccf1f8a71d | |||
| b83a8c9df0 | |||
| a7bd94f394 | |||
| 96787c27d3 | |||
| e99c731e27 | |||
| 4a4b5bd8ef | |||
| 9d27f1aa6b | |||
| fb010038f1 | |||
| 700f86fe38 | |||
| 582a8b9615 | |||
| 7c9dfc352c | |||
| b86d549695 | |||
| 577f6dfd8e | |||
| eab4cce251 | |||
| 7a099ab255 | |||
| 9fa67f61f3 | |||
| 86ebff4948 | |||
| 821ac4668f | |||
| d0280f0ab9 | |||
| ceb45b4c0b | |||
| 85ea3d97ef | |||
| 5640041e45 | |||
| 1357ddc1fe | |||
| 0765dae178 | |||
| 81f2e20c08 | |||
| b27d34ec92 | |||
| e7fbbd579c | |||
| 7a2a82fa87 |
@@ -253,6 +253,8 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
# Отключить пополнение баланса через поддержку
|
||||
SUPPORT_TOPUP_ENABLED=true
|
||||
|
||||
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.7.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.7.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.7.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.7.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.7.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.7.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -12,6 +12,8 @@ docker-compose.override.yml
|
||||
!requirements.txt
|
||||
!docs/
|
||||
!docs/**
|
||||
!migrations/
|
||||
!migrations/**
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.7.0"
|
||||
ARG VERSION="v2.9.0"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ WEBHOOK_SECRET_TOKEN=super-secret-token
|
||||
# Настройки Web API
|
||||
WEB_API_ENABLED=true
|
||||
WEB_API_PORT=8080
|
||||
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
|
||||
WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com
|
||||
WEB_API_DEFAULT_TOKEN=super-secret-token
|
||||
|
||||
# Настройки Remnawave
|
||||
@@ -172,7 +172,7 @@ REMNAWAVE_API_KEY=your_api_key
|
||||
|
||||
```env
|
||||
BOT_RUN_MODE=webhook
|
||||
WEBHOOK_URL=https://api.domain.com
|
||||
WEBHOOK_URL=https://hooks.domain.com
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=super-secret-token
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
@@ -184,8 +184,7 @@ WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
WEB_API_ENABLED=true
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8080
|
||||
WEB_API_ALLOWED_ORIGINS=https://bot.example.com
|
||||
MINIAPP_CUSTOM_URL=https://bot.example.com/miniapp
|
||||
WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com
|
||||
```
|
||||
|
||||
* `WEBHOOK_URL` — публичный HTTPS-домен прокси. К нему автоматически добавится путь из `WEBHOOK_PATH`.
|
||||
@@ -326,7 +325,7 @@ networks:
|
||||
|
||||
Если бот и панель Remnawave запускаются на одном сервере, подключите бота к сети панели:
|
||||
|
||||
**docker-compose.yml бота:**
|
||||
**docker-compose.local.yml бота(есть в репо):**
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
@@ -433,8 +432,6 @@ networks:
|
||||
|
||||
### 5. Проверка здоровья
|
||||
|
||||
Статические файлы миниапки автоматически монтируются из каталога `MINIAPP_STATIC_PATH` (по умолчанию `miniapp/`) и доступны по пути `/miniapp/static`.
|
||||
|
||||
Проверьте, что единый сервер отвечает:
|
||||
|
||||
```bash
|
||||
@@ -458,24 +455,117 @@ curl -s https://bot.example.com/health/unified | jq
|
||||
`Caddyfile`:
|
||||
|
||||
```caddy
|
||||
# API
|
||||
api.domain.com {
|
||||
# Hooks + API
|
||||
hooks.domain.com {
|
||||
encode gzip zstd
|
||||
|
||||
@config path /app-config.json
|
||||
header @config Access-Control-Allow-Origin "*"
|
||||
# Webhook пути для платежных систем
|
||||
handle /yookassa-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
handle /platega-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle /cryptobot-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle /wata-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle /heleket-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle /tribute-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle /pal24-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle /mulenpay-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 "*"
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Все остальные запросы
|
||||
handle {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Статика для miniapp
|
||||
podpiska.domain.com {
|
||||
miniapp.domain.com {
|
||||
encode gzip zstd
|
||||
|
||||
# API эндпоинты /miniapp/* в приложение
|
||||
@@ -518,7 +608,6 @@ podpiska.domain.com {
|
||||
|
||||
```nginx
|
||||
events {}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
sendfile on;
|
||||
@@ -527,17 +616,114 @@ http {
|
||||
server remnawave_bot:8080;
|
||||
}
|
||||
|
||||
# API домен
|
||||
# Hooks + API домен
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name api.domain.com;
|
||||
server_name hooks.domain.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/api.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/api.privkey.pem;
|
||||
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
|
||||
|
||||
client_max_body_size 32m;
|
||||
|
||||
# Webhook пути для платежных систем
|
||||
location = /yookassa-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;
|
||||
}
|
||||
|
||||
location = /platega-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;
|
||||
}
|
||||
|
||||
location = /cryptobot-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;
|
||||
}
|
||||
|
||||
location = /wata-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;
|
||||
}
|
||||
|
||||
location = /heleket-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;
|
||||
}
|
||||
|
||||
location = /tribute-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;
|
||||
}
|
||||
|
||||
location = /pal24-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;
|
||||
}
|
||||
|
||||
location = /mulenpay-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 "*";
|
||||
@@ -566,10 +752,10 @@ http {
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name podpiska.domain.com;
|
||||
server_name miniapp.domain.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/podpiska.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/podpiska.privkey.pem;
|
||||
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
|
||||
|
||||
client_max_body_size 32m;
|
||||
|
||||
@@ -617,6 +803,102 @@ http {
|
||||
- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`.
|
||||
- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook.
|
||||
|
||||
### Быстрая настройка SSL для Nginx в Docker
|
||||
|
||||
#### 1. Установка Certbot
|
||||
```bash
|
||||
sudo apt update && sudo apt install certbot -y
|
||||
```
|
||||
|
||||
#### 2. Генерация сертификатов
|
||||
```bash
|
||||
# Остановите Nginx Docker контейнер
|
||||
docker compose down
|
||||
|
||||
# Сгенерируйте сертификаты (порт 80 должен быть свободен)
|
||||
sudo certbot certonly --standalone -d hooks.domain.com --agree-tos --email your-email@example.com --non-interactive
|
||||
sudo certbot certonly --standalone -d miniapp.domain.com --agree-tos --email your-email@example.com --non-interactive
|
||||
```
|
||||
|
||||
#### 3. Копирование сертификатов
|
||||
```bash
|
||||
sudo mkdir -p /etc/ssl/private
|
||||
|
||||
sudo cp /etc/letsencrypt/live/hooks.domain.com/fullchain.pem /etc/ssl/private/hooks.fullchain.pem
|
||||
sudo cp /etc/letsencrypt/live/hooks.domain.com/privkey.pem /etc/ssl/private/hooks.privkey.pem
|
||||
sudo cp /etc/letsencrypt/live/miniapp.domain.com/fullchain.pem /etc/ssl/private/miniapp.fullchain.pem
|
||||
sudo cp /etc/letsencrypt/live/miniapp.domain.com/privkey.pem /etc/ssl/private/miniapp.privkey.pem
|
||||
|
||||
sudo chmod 600 /etc/ssl/private/*.pem
|
||||
```
|
||||
|
||||
#### 4. Обновите docker-compose.yml
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
container_name: nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- /etc/ssl/private:/etc/ssl/private:ro
|
||||
- /var/www/remnawave-miniapp:/var/www/remnawave-miniapp:ro
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
remnawave-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
#### 5. Запуск Nginx
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### 6. Автообновление сертификатов
|
||||
```bash
|
||||
# Создайте скрипт обновления
|
||||
sudo tee /opt/renew-certs.sh > /dev/null <<'EOF'
|
||||
#!/bin/bash
|
||||
# Останавливаем Nginx для освобождения порта 80
|
||||
docker compose -f /путь/к/docker-compose.yml down
|
||||
|
||||
# Обновляем сертификаты
|
||||
certbot renew --quiet
|
||||
|
||||
# Копируем обновленные сертификаты
|
||||
cp /etc/letsencrypt/live/hooks.domain.com/fullchain.pem /etc/ssl/private/hooks.fullchain.pem
|
||||
cp /etc/letsencrypt/live/hooks.domain.com/privkey.pem /etc/ssl/private/hooks.privkey.pem
|
||||
cp /etc/letsencrypt/live/miniapp.domain.com/fullchain.pem /etc/ssl/private/miniapp.fullchain.pem
|
||||
cp /etc/letsencrypt/live/miniapp.domain.com/privkey.pem /etc/ssl/private/miniapp.privkey.pem
|
||||
chmod 600 /etc/ssl/private/*.pem
|
||||
|
||||
# Запускаем Nginx обратно
|
||||
docker compose -f /путь/к/docker-compose.yml up -d
|
||||
EOF
|
||||
|
||||
# Сделайте скрипт исполняемым
|
||||
sudo chmod +x /opt/renew-certs.sh
|
||||
|
||||
# Добавьте в cron (запуск каждый день в 3:00 AM)
|
||||
echo "0 3 * * * /opt/renew-certs.sh" | sudo crontab -
|
||||
```
|
||||
|
||||
#### Проверка работы SSL
|
||||
```bash
|
||||
curl -I https://hooks.domain.com
|
||||
curl -I https://miniapp.domain.com
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
- Замените `domain.com` и `your-email@example.com` на свои данные
|
||||
- Замените `/путь/к/docker-compose.yml` на реальный путь к вашему docker-compose файлу
|
||||
- Убедитесь, что DNS записи для доменов указывают на IP вашего сервера
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Конфигурация
|
||||
@@ -904,6 +1186,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- ⏰ **Продление/сокращение срока** подписки (±365 дней)
|
||||
- 🚫 Блокировки с таймером и аудит действий
|
||||
- 🛡️ **Защита от запрещенных никнеймов** с настраиваемым список банвордов (автоблокировка подозрительных имен)
|
||||
- 💰 **Установка индивидуального реферального процента юзеру**
|
||||
|
||||
🎯 **Продажи, маркетинг и удержание**
|
||||
- 🎫 Промокоды
|
||||
@@ -949,6 +1232,15 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 📘 **Управление пакетами трафика** (включение/отключение)
|
||||
- 🧪 Тестовые платежи для каждого провайдера
|
||||
- 🪙 Управление вебхуками всех платёжных систем
|
||||
- ⚙️ **Управление настройками из бота** (с приоритетом в .env)
|
||||
|
||||
⚙️ **Remnawave**
|
||||
- Синхронизация юзеров из панели в бота (Ручная/автоматическая по таймеру)
|
||||
- Синхронизация юзеров из бота в панель
|
||||
- Синхронизация сквадов(серверов) из панели в бота
|
||||
- Управление нодами/сквадами прямо в боте
|
||||
- Детальная статистика по нодам/панели
|
||||
- Создание/Редактивание сквадов в боте
|
||||
|
||||
🗃️ **REST API для интеграций**
|
||||
- 🔌 **FastAPI Web API** с полной документацией
|
||||
@@ -1453,17 +1745,6 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
|
||||
### 🆘 Частые вопросы
|
||||
|
||||
<details>
|
||||
<summary><b>Как начать работу?</b></summary>
|
||||
|
||||
1. Скачайте репозиторий
|
||||
2. Запустите `install_bot.sh`
|
||||
3. Следуйте инструкциям установщика
|
||||
4. Синхронизируйте серверы в админке
|
||||
5. Готово! 🎉
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Какие требования к серверу?</b></summary>
|
||||
|
||||
@@ -1498,18 +1779,26 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
<details>
|
||||
<summary><b>Как обновить бота?</b></summary>
|
||||
|
||||
**Через install_bot.sh (рекомендуется):**
|
||||
```bash
|
||||
./install_bot.sh
|
||||
# Выбрать: 4. 🔄 Обновить проект из Git
|
||||
```
|
||||
|
||||
**Вручную:**
|
||||
```bash
|
||||
git pull
|
||||
docker compose down
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
cd /root/remnawave-bedolaga-telegram-bot
|
||||
|
||||
# Обновление до последнего коммита (до последней мастер ветки, не всегда стабильно):
|
||||
|
||||
git pull origin main
|
||||
|
||||
# Обновление до конкретной версии (более стабильно, релиз версии):
|
||||
|
||||
git fetch --tags
|
||||
git checkout v2.7.0
|
||||
|
||||
# Перезагружаем конты:
|
||||
|
||||
make reload
|
||||
|
||||
# Перезагружаем конты с логами:
|
||||
|
||||
make reload-follow
|
||||
```
|
||||
|
||||
Скрипт автоматически создаст бэкап перед обновлением!
|
||||
@@ -1523,12 +1812,6 @@ docker compose up -d
|
||||
- Настройте в `.env`: `BACKUP_AUTO_ENABLED=true`
|
||||
- Бэкапы создаются по расписанию
|
||||
|
||||
**Вручную через install_bot.sh:**
|
||||
```bash
|
||||
./install_bot.sh
|
||||
# Выбрать: 5. 💾 Создать резервную копию
|
||||
```
|
||||
|
||||
**Через админ-панель:**
|
||||
- Админ панель → Настройки → Бэкапы → Создать
|
||||
|
||||
|
||||
+55
-11
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import html
|
||||
@@ -19,6 +20,8 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
|
||||
"joingroup",
|
||||
]
|
||||
|
||||
USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,6 +91,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
TRIAL_USER_TAG: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
@@ -119,6 +123,7 @@ class Settings(BaseSettings):
|
||||
PRICE_90_DAYS: int = 269000
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
PAID_SUBSCRIPTION_USER_TAG: Optional[str] = None
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 2000
|
||||
PRICE_TRAFFIC_10GB: int = 3500
|
||||
@@ -194,6 +199,7 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
SUPPORT_TOPUP_ENABLED: bool = True
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
|
||||
@@ -775,13 +781,48 @@ class Settings(BaseSettings):
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
|
||||
|
||||
def rubles_to_kopeks(self, rubles: float) -> int:
|
||||
return int(rubles * 100)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _normalize_user_tag(value: Optional[str], setting_name: str) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
cleaned = str(value).strip().upper()
|
||||
if not cleaned:
|
||||
return None
|
||||
|
||||
if len(cleaned) > 16:
|
||||
logger.warning(
|
||||
"Некорректная длина %s: максимум 16 символов, получено %s",
|
||||
setting_name,
|
||||
len(cleaned),
|
||||
)
|
||||
return None
|
||||
|
||||
if not USER_TAG_PATTERN.fullmatch(cleaned):
|
||||
logger.warning(
|
||||
"Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание",
|
||||
setting_name,
|
||||
)
|
||||
return None
|
||||
|
||||
return cleaned
|
||||
|
||||
def get_trial_warning_hours(self) -> int:
|
||||
return self.TRIAL_WARNING_HOURS
|
||||
|
||||
def get_trial_user_tag(self) -> Optional[str]:
|
||||
return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG")
|
||||
|
||||
def get_paid_subscription_user_tag(self) -> Optional[str]:
|
||||
return self._normalize_user_tag(
|
||||
self.PAID_SUBSCRIPTION_USER_TAG,
|
||||
"PAID_SUBSCRIPTION_USER_TAG",
|
||||
)
|
||||
|
||||
def get_bot_username(self) -> Optional[str]:
|
||||
username = getattr(self, "BOT_USERNAME", None)
|
||||
if not username:
|
||||
@@ -931,9 +972,12 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
self.YOOKASSA_SHOP_ID is not None and
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
self.YOOKASSA_SHOP_ID is not None and
|
||||
self.YOOKASSA_SECRET_KEY is not None)
|
||||
|
||||
def is_support_topup_enabled(self) -> bool:
|
||||
return bool(self.SUPPORT_TOPUP_ENABLED)
|
||||
|
||||
def get_yookassa_return_url(self) -> str:
|
||||
if self.YOOKASSA_RETURN_URL:
|
||||
@@ -1280,7 +1324,7 @@ class Settings(BaseSettings):
|
||||
return stars * self.get_stars_rate()
|
||||
|
||||
def rubles_to_stars(self, rubles: float) -> int:
|
||||
return max(1, int(rubles / self.get_stars_rate()))
|
||||
return max(1, math.ceil(rubles / self.get_stars_rate()))
|
||||
|
||||
def get_admin_notifications_chat_id(self) -> Optional[int]:
|
||||
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
||||
@@ -1332,13 +1376,13 @@ class Settings(BaseSettings):
|
||||
packages = []
|
||||
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
||||
|
||||
logger.info(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
logger.debug(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
if not config_str:
|
||||
logger.info("CONFIG EMPTY, USING FALLBACK")
|
||||
logger.debug("CONFIG EMPTY, USING FALLBACK")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
logger.info("PARSING CONFIG...")
|
||||
|
||||
logger.debug("PARSING CONFIG...")
|
||||
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
@@ -1362,7 +1406,7 @@ class Settings(BaseSettings):
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.info(f"PARSED {len(packages)} packages from config")
|
||||
logger.debug(f"PARSED {len(packages)} packages from config")
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -988,8 +988,8 @@ async def calculate_subscription_total_cost(
|
||||
]
|
||||
}
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
logger.debug(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.debug(f" Базовый период: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
message = (
|
||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽"
|
||||
@@ -998,7 +998,7 @@ async def calculate_subscription_total_cost(
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽"
|
||||
@@ -1007,7 +1007,7 @@ async def calculate_subscription_total_cost(
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if total_devices_price > 0:
|
||||
message = (
|
||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽"
|
||||
@@ -1016,8 +1016,8 @@ async def calculate_subscription_total_cost(
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
logger.debug(message)
|
||||
logger.debug(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SubscriptionEvent
|
||||
|
||||
|
||||
async def create_subscription_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
event_type: str,
|
||||
subscription_id: Optional[int] = None,
|
||||
transaction_id: Optional[int] = None,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
currency: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
occurred_at: Optional[datetime] = None,
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> SubscriptionEvent:
|
||||
event = SubscriptionEvent(
|
||||
user_id=user_id,
|
||||
event_type=event_type,
|
||||
subscription_id=subscription_id,
|
||||
transaction_id=transaction_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
message=message,
|
||||
occurred_at=occurred_at or datetime.utcnow(),
|
||||
extra=extra or None,
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
async def list_subscription_events(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
event_types: Optional[Iterable[str]] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> Tuple[list[SubscriptionEvent], int]:
|
||||
base_query = select(SubscriptionEvent)
|
||||
filters = []
|
||||
|
||||
if event_types:
|
||||
filters.append(SubscriptionEvent.event_type.in_(set(event_types)))
|
||||
if user_id:
|
||||
filters.append(SubscriptionEvent.user_id == user_id)
|
||||
|
||||
if filters:
|
||||
base_query = base_query.where(and_(*filters))
|
||||
|
||||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||
total = await db.scalar(total_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.options(selectinload(SubscriptionEvent.user))
|
||||
.order_by(SubscriptionEvent.occurred_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
return result.scalars().all(), int(total)
|
||||
@@ -5,7 +5,8 @@ from typing import Optional, List
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import UserMessage
|
||||
from app.database.models import User, UserMessage
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,15 +14,25 @@ logger = logging.getLogger(__name__)
|
||||
async def create_user_message(
|
||||
db: AsyncSession,
|
||||
message_text: str,
|
||||
created_by: int,
|
||||
created_by: Optional[int] = None,
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0
|
||||
) -> UserMessage:
|
||||
is_valid, error_message = validate_html_tags(message_text)
|
||||
if not is_valid:
|
||||
raise ValueError(error_message)
|
||||
|
||||
resolved_creator = created_by
|
||||
|
||||
if created_by is not None:
|
||||
result = await db.execute(select(User.id).where(User.id == created_by))
|
||||
resolved_creator = result.scalar_one_or_none()
|
||||
|
||||
message = UserMessage(
|
||||
message_text=message_text,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
created_by=created_by
|
||||
created_by=resolved_creator,
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
@@ -55,25 +66,33 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]:
|
||||
return None
|
||||
|
||||
random_message = random.choice(active_messages)
|
||||
return random_message.message_text
|
||||
return sanitize_html(random_message.message_text)
|
||||
|
||||
|
||||
async def get_all_user_messages(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50
|
||||
limit: int = 50,
|
||||
include_inactive: bool = True,
|
||||
) -> List[UserMessage]:
|
||||
query = select(UserMessage).order_by(UserMessage.created_at.desc())
|
||||
if not include_inactive:
|
||||
query = query.where(UserMessage.is_active == True)
|
||||
|
||||
result = await db.execute(
|
||||
select(UserMessage)
|
||||
.order_by(UserMessage.created_at.desc())
|
||||
query
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_messages_count(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(UserMessage.id)))
|
||||
async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int:
|
||||
query = select(func.count(UserMessage.id))
|
||||
if not include_inactive:
|
||||
query = query.where(UserMessage.is_active == True)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
@@ -88,8 +107,11 @@ async def update_user_message(
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
|
||||
if message_text is not None:
|
||||
is_valid, error_message = validate_html_tags(message_text)
|
||||
if not is_valid:
|
||||
raise ValueError(error_message)
|
||||
message.message_text = message_text
|
||||
|
||||
if is_active is not None:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WelcomeText
|
||||
from app.database.models import User, WelcomeText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +45,37 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
|
||||
'id': None
|
||||
}
|
||||
|
||||
|
||||
async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]:
|
||||
result = await db.execute(
|
||||
select(WelcomeText).where(WelcomeText.id == welcome_text_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_welcome_texts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = True,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
):
|
||||
query = select(WelcomeText).order_by(WelcomeText.updated_at.desc())
|
||||
if not include_inactive:
|
||||
query = query.where(WelcomeText.is_active == True)
|
||||
|
||||
result = await db.execute(query.limit(limit).offset(offset))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int:
|
||||
query = select(func.count(WelcomeText.id))
|
||||
if not include_inactive:
|
||||
query = query.where(WelcomeText.is_active == True)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
||||
try:
|
||||
result = await db.execute(
|
||||
@@ -113,6 +144,87 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def create_welcome_text(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
text_content: str,
|
||||
created_by: Optional[int] = None,
|
||||
is_enabled: bool = True,
|
||||
is_active: bool = True,
|
||||
) -> WelcomeText:
|
||||
resolved_creator = created_by
|
||||
|
||||
if created_by is not None:
|
||||
result = await db.execute(select(User.id).where(User.id == created_by))
|
||||
resolved_creator = result.scalar_one_or_none()
|
||||
|
||||
if is_active:
|
||||
await db.execute(update(WelcomeText).values(is_active=False))
|
||||
|
||||
welcome_text = WelcomeText(
|
||||
text_content=text_content,
|
||||
is_active=is_active,
|
||||
is_enabled=is_enabled,
|
||||
created_by=resolved_creator,
|
||||
)
|
||||
|
||||
db.add(welcome_text)
|
||||
await db.commit()
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
logger.info(
|
||||
"✅ Создан приветственный текст ID %s (активный=%s, включен=%s)",
|
||||
welcome_text.id,
|
||||
welcome_text.is_active,
|
||||
welcome_text.is_enabled,
|
||||
)
|
||||
return welcome_text
|
||||
|
||||
|
||||
async def update_welcome_text(
|
||||
db: AsyncSession,
|
||||
welcome_text: WelcomeText,
|
||||
*,
|
||||
text_content: Optional[str] = None,
|
||||
is_enabled: Optional[bool] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> WelcomeText:
|
||||
if is_active:
|
||||
await db.execute(
|
||||
update(WelcomeText)
|
||||
.where(WelcomeText.id != welcome_text.id)
|
||||
.values(is_active=False)
|
||||
)
|
||||
|
||||
if text_content is not None:
|
||||
welcome_text.text_content = text_content
|
||||
|
||||
if is_enabled is not None:
|
||||
welcome_text.is_enabled = is_enabled
|
||||
|
||||
if is_active is not None:
|
||||
welcome_text.is_active = is_active
|
||||
|
||||
welcome_text.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
logger.info(
|
||||
"📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)",
|
||||
welcome_text.id,
|
||||
welcome_text.is_active,
|
||||
welcome_text.is_enabled,
|
||||
)
|
||||
return welcome_text
|
||||
|
||||
|
||||
async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None:
|
||||
await db.delete(welcome_text)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id)
|
||||
|
||||
async def get_current_welcome_text_or_default() -> str:
|
||||
return (
|
||||
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
|
||||
|
||||
+30
-1
@@ -541,7 +541,11 @@ class PromoGroup(Base):
|
||||
"traffic": self.traffic_discount_percent,
|
||||
"devices": self.device_discount_percent,
|
||||
}
|
||||
percent = mapping.get(category, 0)
|
||||
percent = mapping.get(category) or 0
|
||||
|
||||
if percent == 0 and self.is_default:
|
||||
base_period_discount = self._get_period_discount(period_days)
|
||||
percent = max(percent, base_period_discount)
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
@@ -591,6 +595,7 @@ class User(Base):
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
|
||||
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
|
||||
referral_commission_percent = Column(Integer, nullable=True)
|
||||
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
promo_offer_discount_source = Column(String(100), nullable=True)
|
||||
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
|
||||
@@ -1078,6 +1083,30 @@ class SentNotification(Base):
|
||||
subscription = relationship("Subscription", backref="sent_notifications")
|
||||
|
||||
|
||||
class SubscriptionEvent(Base):
|
||||
__tablename__ = "subscription_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
event_type = Column(String(50), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
subscription_id = Column(
|
||||
Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
transaction_id = Column(
|
||||
Integer, ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
amount_kopeks = Column(Integer, nullable=True)
|
||||
currency = Column(String(16), nullable=True)
|
||||
message = Column(Text, nullable=True)
|
||||
occurred_at = Column(DateTime, nullable=False, default=func.now())
|
||||
extra = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", backref="subscription_events")
|
||||
subscription = relationship("Subscription", backref="subscription_events")
|
||||
transaction = relationship("Transaction", backref="subscription_events")
|
||||
|
||||
|
||||
class DiscountOffer(Base):
|
||||
__tablename__ = "discount_offers"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -2742,6 +2742,35 @@ async def fix_foreign_keys_for_user_deletion():
|
||||
logger.error(f"Ошибка обновления внешних ключей: {e}")
|
||||
return False
|
||||
|
||||
async def add_referral_commission_percent_column() -> bool:
|
||||
column_exists = await check_column_exists('users', 'referral_commission_percent')
|
||||
if column_exists:
|
||||
logger.info("ℹ️ Колонка referral_commission_percent уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL"
|
||||
elif db_type == 'postgresql':
|
||||
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL"
|
||||
elif db_type == 'mysql':
|
||||
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INT NULL"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для добавления referral_commission_percent: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info("✅ Добавлена колонка referral_commission_percent в таблицу users")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления referral_commission_percent: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_referral_system_columns():
|
||||
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
|
||||
|
||||
@@ -2867,6 +2896,94 @@ async def create_subscription_conversions_table():
|
||||
logger.error(f"Ошибка создания таблицы subscription_conversions: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_subscription_events_table():
|
||||
table_exists = await check_table_exists("subscription_events")
|
||||
if table_exists:
|
||||
logger.info("Таблица subscription_events уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
subscription_id INTEGER NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
amount_kopeks INTEGER NULL,
|
||||
currency VARCHAR(16) NULL,
|
||||
message TEXT NULL,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
extra JSON NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
|
||||
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
|
||||
"""
|
||||
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL,
|
||||
transaction_id INTEGER NULL REFERENCES transactions(id) ON DELETE SET NULL,
|
||||
amount_kopeks INTEGER NULL,
|
||||
currency VARCHAR(16) NULL,
|
||||
message TEXT NULL,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
extra JSON NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
|
||||
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
|
||||
"""
|
||||
|
||||
elif db_type == "mysql":
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_events (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
subscription_id INT NULL,
|
||||
transaction_id INT NULL,
|
||||
amount_kopeks INT NULL,
|
||||
currency VARCHAR(16) NULL,
|
||||
message TEXT NULL,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
extra JSON NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
|
||||
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы subscription_events: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица subscription_events успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы subscription_events: {e}")
|
||||
return False
|
||||
|
||||
async def fix_subscription_duplicates_universal():
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
@@ -3721,6 +3838,12 @@ async def run_universal_migration():
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
commission_column_ready = await add_referral_commission_percent_column()
|
||||
if commission_column_ready:
|
||||
logger.info("✅ Колонка referral_commission_percent готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонкой referral_commission_percent")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===")
|
||||
system_settings_ready = await create_system_settings_table()
|
||||
if system_settings_ready:
|
||||
@@ -4052,7 +4175,14 @@ async def run_universal_migration():
|
||||
logger.info("✅ Таблица subscription_conversions готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
|
||||
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_EVENTS ===")
|
||||
events_created = await create_subscription_events_table()
|
||||
if events_created:
|
||||
logger.info("✅ Таблица subscription_events готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_events")
|
||||
|
||||
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"))
|
||||
@@ -4089,6 +4219,7 @@ async def run_universal_migration():
|
||||
logger.info("✅ CryptoBot таблица готова")
|
||||
logger.info("✅ Heleket таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Таблица событий подписок создана")
|
||||
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
||||
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
||||
logger.info("✅ Дубликаты подписок исправлены")
|
||||
@@ -4112,6 +4243,7 @@ async def check_migration_status():
|
||||
"broadcast_history_media_fields": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False,
|
||||
"subscription_events_table": False,
|
||||
"promo_groups_table": False,
|
||||
"server_promo_groups_table": False,
|
||||
"server_squads_trial_column": False,
|
||||
@@ -4126,6 +4258,7 @@ async def check_migration_status():
|
||||
"users_promo_offer_discount_percent_column": False,
|
||||
"users_promo_offer_discount_source_column": False,
|
||||
"users_promo_offer_discount_expires_column": False,
|
||||
"users_referral_commission_percent_column": False,
|
||||
"subscription_crypto_link_column": False,
|
||||
"discount_offers_table": False,
|
||||
"discount_offers_effect_column": False,
|
||||
@@ -4145,6 +4278,7 @@ async def check_migration_status():
|
||||
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
|
||||
status["public_offers_table"] = await check_table_exists('public_offers')
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
status["subscription_events_table"] = await check_table_exists('subscription_events')
|
||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
|
||||
status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible')
|
||||
@@ -4167,6 +4301,7 @@ async def check_migration_status():
|
||||
status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent')
|
||||
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
|
||||
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
|
||||
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
|
||||
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||
|
||||
media_fields_exist = (
|
||||
@@ -4200,6 +4335,7 @@ async def check_migration_status():
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_events_table": "Таблица событий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок",
|
||||
"promo_groups_table": "Таблица промо-групп",
|
||||
"server_promo_groups_table": "Связи серверов и промогрупп",
|
||||
@@ -4213,6 +4349,7 @@ async def check_migration_status():
|
||||
"users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей",
|
||||
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
|
||||
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
|
||||
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
|
||||
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
||||
"discount_offers_table": "Таблица discount_offers",
|
||||
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
|
||||
|
||||
Vendored
+230
-29
@@ -27,14 +27,22 @@ class TrafficLimitStrategy(Enum):
|
||||
MONTH = "MONTH"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserTraffic:
|
||||
"""Данные о трафике пользователя (новая структура API)"""
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
online_at: Optional[datetime] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_connected_node_uuid: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveUser:
|
||||
uuid: str
|
||||
short_uuid: str
|
||||
username: str
|
||||
status: UserStatus
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
traffic_limit_bytes: int
|
||||
traffic_limit_strategy: TrafficLimitStrategy
|
||||
expire_at: datetime
|
||||
@@ -47,18 +55,60 @@ class RemnaWaveUser:
|
||||
active_internal_squads: List[Dict[str, str]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user_traffic: Optional[UserTraffic] = None
|
||||
sub_last_user_agent: Optional[str] = None
|
||||
sub_last_opened_at: Optional[datetime] = None
|
||||
online_at: Optional[datetime] = None
|
||||
sub_revoked_at: Optional[datetime] = None
|
||||
last_traffic_reset_at: Optional[datetime] = None
|
||||
trojan_password: Optional[str] = None
|
||||
vless_uuid: Optional[str] = None
|
||||
ss_password: Optional[str] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_triggered_threshold: int = 0
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
external_squad_uuid: Optional[str] = None
|
||||
id: Optional[int] = None
|
||||
|
||||
@property
|
||||
def used_traffic_bytes(self) -> int:
|
||||
"""Обратная совместимость: получение used_traffic_bytes из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.used_traffic_bytes
|
||||
return 0
|
||||
|
||||
@property
|
||||
def lifetime_used_traffic_bytes(self) -> int:
|
||||
"""Обратная совместимость: получение lifetime_used_traffic_bytes из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.lifetime_used_traffic_bytes
|
||||
return 0
|
||||
|
||||
@property
|
||||
def online_at(self) -> Optional[datetime]:
|
||||
"""Обратная совместимость: получение online_at из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.online_at
|
||||
return None
|
||||
|
||||
@property
|
||||
def first_connected_at(self) -> Optional[datetime]:
|
||||
"""Обратная совместимость: получение first_connected_at из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.first_connected_at
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveInbound:
|
||||
"""Структура inbound для Internal Squad"""
|
||||
uuid: str
|
||||
profile_uuid: str
|
||||
tag: str
|
||||
type: str
|
||||
network: Optional[str] = None
|
||||
security: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
raw_inbound: Optional[Any] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,7 +117,21 @@ class RemnaWaveInternalSquad:
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: List[Dict[str, Any]]
|
||||
inbounds: List[RemnaWaveInbound]
|
||||
view_position: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveAccessibleNode:
|
||||
"""Доступная нода для Internal Squad"""
|
||||
uuid: str
|
||||
node_name: str
|
||||
country_code: str
|
||||
config_profile_uuid: str
|
||||
config_profile_name: str
|
||||
active_inbounds: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -78,11 +142,39 @@ class RemnaWaveNode:
|
||||
country_code: str
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: Optional[int]
|
||||
traffic_used_bytes: Optional[int]
|
||||
traffic_limit_bytes: Optional[int]
|
||||
port: Optional[int] = None
|
||||
is_connecting: bool = False
|
||||
xray_version: Optional[str] = None
|
||||
node_version: Optional[str] = None
|
||||
view_position: int = 0
|
||||
tags: Optional[List[str]] = None
|
||||
# Новые поля API
|
||||
last_status_change: Optional[datetime] = None
|
||||
last_status_message: Optional[str] = None
|
||||
xray_uptime: Optional[str] = None
|
||||
is_traffic_tracking_active: bool = False
|
||||
traffic_reset_day: Optional[int] = None
|
||||
notify_percent: Optional[int] = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: Optional[int] = None
|
||||
cpu_model: Optional[str] = None
|
||||
total_ram: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
provider_uuid: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_node_online(self) -> bool:
|
||||
"""Обратная совместимость: is_node_online = is_connected"""
|
||||
return self.is_connected
|
||||
|
||||
@property
|
||||
def is_xray_running(self) -> bool:
|
||||
"""Обратная совместимость: xray работает если нода подключена"""
|
||||
return self.is_connected
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -444,8 +536,38 @@ class RemnaWaveAPI:
|
||||
async def delete_internal_squad(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
|
||||
|
||||
async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[RemnaWaveAccessibleNode]:
|
||||
"""Получает список доступных нод для Internal Squad"""
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/internal-squads/{uuid}/accessible-nodes')
|
||||
return [self._parse_accessible_node(node) for node in response['response']['accessibleNodes']]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def add_users_to_internal_squad(self, uuid: str) -> bool:
|
||||
"""Добавляет всех пользователей в Internal Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/add-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def remove_users_from_internal_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет всех пользователей из Internal Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def reorder_internal_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveInternalSquad]:
|
||||
"""
|
||||
Изменяет порядок Internal Squads
|
||||
items: список словарей с uuid и viewPosition
|
||||
Пример: [{'uuid': '...', 'viewPosition': 0}, {'uuid': '...', 'viewPosition': 1}]
|
||||
"""
|
||||
data = {'items': items}
|
||||
response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data)
|
||||
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
|
||||
|
||||
|
||||
async def get_all_nodes(self) -> List[RemnaWaveNode]:
|
||||
response = await self._make_request('GET', '/api/nodes')
|
||||
return [self._parse_node(node) for node in response['response']]
|
||||
@@ -586,42 +708,73 @@ class RemnaWaveAPI:
|
||||
return False
|
||||
|
||||
|
||||
def _parse_user_traffic(self, traffic_data: Optional[Dict]) -> Optional[UserTraffic]:
|
||||
"""Парсит данные трафика из нового формата API"""
|
||||
if not traffic_data:
|
||||
return None
|
||||
|
||||
return UserTraffic(
|
||||
used_traffic_bytes=int(traffic_data.get('usedTrafficBytes', 0)),
|
||||
lifetime_used_traffic_bytes=int(traffic_data.get('lifetimeUsedTrafficBytes', 0)),
|
||||
online_at=self._parse_optional_datetime(traffic_data.get('onlineAt')),
|
||||
first_connected_at=self._parse_optional_datetime(traffic_data.get('firstConnectedAt')),
|
||||
last_connected_node_uuid=traffic_data.get('lastConnectedNodeUuid')
|
||||
)
|
||||
|
||||
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
|
||||
happ_data = user_data.get('happ') or {}
|
||||
happ_link = happ_data.get('link') or happ_data.get('url')
|
||||
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
|
||||
|
||||
# Парсим userTraffic из нового формата API
|
||||
user_traffic = self._parse_user_traffic(user_data.get('userTraffic'))
|
||||
|
||||
# Получаем status с fallback на ACTIVE
|
||||
status_str = user_data.get('status') or 'ACTIVE'
|
||||
try:
|
||||
status = UserStatus(status_str)
|
||||
except ValueError:
|
||||
logger.warning(f"Неизвестный статус пользователя: {status_str}, используем ACTIVE")
|
||||
status = UserStatus.ACTIVE
|
||||
|
||||
# Получаем trafficLimitStrategy с fallback
|
||||
strategy_str = user_data.get('trafficLimitStrategy') or 'NO_RESET'
|
||||
try:
|
||||
traffic_strategy = TrafficLimitStrategy(strategy_str)
|
||||
except ValueError:
|
||||
logger.warning(f"Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET")
|
||||
traffic_strategy = TrafficLimitStrategy.NO_RESET
|
||||
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
username=user_data['username'],
|
||||
status=UserStatus(user_data['status']),
|
||||
used_traffic_bytes=int(user_data['usedTrafficBytes']),
|
||||
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
|
||||
traffic_limit_bytes=user_data['trafficLimitBytes'],
|
||||
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
|
||||
status=status,
|
||||
traffic_limit_bytes=user_data.get('trafficLimitBytes', 0),
|
||||
traffic_limit_strategy=traffic_strategy,
|
||||
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
|
||||
telegram_id=user_data.get('telegramId'),
|
||||
email=user_data.get('email'),
|
||||
hwid_device_limit=user_data.get('hwidDeviceLimit'),
|
||||
description=user_data.get('description'),
|
||||
tag=user_data.get('tag'),
|
||||
subscription_url=user_data['subscriptionUrl'],
|
||||
active_internal_squads=user_data['activeInternalSquads'],
|
||||
subscription_url=user_data.get('subscriptionUrl', ''),
|
||||
active_internal_squads=user_data.get('activeInternalSquads', []),
|
||||
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
|
||||
user_traffic=user_traffic,
|
||||
sub_last_user_agent=user_data.get('subLastUserAgent'),
|
||||
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
|
||||
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
|
||||
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
|
||||
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
|
||||
trojan_password=user_data.get('trojanPassword'),
|
||||
vless_uuid=user_data.get('vlessUuid'),
|
||||
ss_password=user_data.get('ssPassword'),
|
||||
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
happ_crypto_link=happ_crypto_link,
|
||||
external_squad_uuid=user_data.get('externalSquadUuid'),
|
||||
id=user_data.get('id')
|
||||
)
|
||||
|
||||
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
@@ -629,28 +782,76 @@ class RemnaWaveAPI:
|
||||
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return None
|
||||
|
||||
def _parse_inbound(self, inbound_data: Dict) -> RemnaWaveInbound:
|
||||
"""Парсит данные inbound"""
|
||||
return RemnaWaveInbound(
|
||||
uuid=inbound_data['uuid'],
|
||||
profile_uuid=inbound_data['profileUuid'],
|
||||
tag=inbound_data['tag'],
|
||||
type=inbound_data['type'],
|
||||
network=inbound_data.get('network'),
|
||||
security=inbound_data.get('security'),
|
||||
port=inbound_data.get('port'),
|
||||
raw_inbound=inbound_data.get('rawInbound')
|
||||
)
|
||||
|
||||
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
|
||||
info = squad_data.get('info', {})
|
||||
inbounds_raw = squad_data.get('inbounds', [])
|
||||
inbounds = [self._parse_inbound(ib) for ib in inbounds_raw] if inbounds_raw else []
|
||||
return RemnaWaveInternalSquad(
|
||||
uuid=squad_data['uuid'],
|
||||
name=squad_data['name'],
|
||||
members_count=squad_data['info']['membersCount'],
|
||||
inbounds_count=squad_data['info']['inboundsCount'],
|
||||
inbounds=squad_data['inbounds']
|
||||
members_count=info.get('membersCount', 0),
|
||||
inbounds_count=info.get('inboundsCount', 0),
|
||||
inbounds=inbounds,
|
||||
view_position=squad_data.get('viewPosition', 0),
|
||||
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
|
||||
)
|
||||
|
||||
|
||||
def _parse_accessible_node(self, node_data: Dict) -> RemnaWaveAccessibleNode:
|
||||
"""Парсит данные доступной ноды для Internal Squad"""
|
||||
return RemnaWaveAccessibleNode(
|
||||
uuid=node_data['uuid'],
|
||||
node_name=node_data['nodeName'],
|
||||
country_code=node_data['countryCode'],
|
||||
config_profile_uuid=node_data['configProfileUuid'],
|
||||
config_profile_name=node_data['configProfileName'],
|
||||
active_inbounds=node_data.get('activeInbounds', [])
|
||||
)
|
||||
|
||||
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
|
||||
return RemnaWaveNode(
|
||||
uuid=node_data['uuid'],
|
||||
name=node_data['name'],
|
||||
address=node_data['address'],
|
||||
country_code=node_data['countryCode'],
|
||||
is_connected=node_data['isConnected'],
|
||||
is_disabled=node_data['isDisabled'],
|
||||
is_node_online=node_data['isNodeOnline'],
|
||||
is_xray_running=node_data['isXrayRunning'],
|
||||
country_code=node_data.get('countryCode', ''),
|
||||
is_connected=node_data.get('isConnected', False),
|
||||
is_disabled=node_data.get('isDisabled', False),
|
||||
users_online=node_data.get('usersOnline'),
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes')
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes'),
|
||||
port=node_data.get('port'),
|
||||
is_connecting=node_data.get('isConnecting', False),
|
||||
xray_version=node_data.get('xrayVersion'),
|
||||
node_version=node_data.get('nodeVersion'),
|
||||
view_position=node_data.get('viewPosition', 0),
|
||||
tags=node_data.get('tags', []),
|
||||
# Новые поля API
|
||||
last_status_change=self._parse_optional_datetime(node_data.get('lastStatusChange')),
|
||||
last_status_message=node_data.get('lastStatusMessage'),
|
||||
xray_uptime=node_data.get('xrayUptime'),
|
||||
is_traffic_tracking_active=node_data.get('isTrafficTrackingActive', False),
|
||||
traffic_reset_day=node_data.get('trafficResetDay'),
|
||||
notify_percent=node_data.get('notifyPercent'),
|
||||
consumption_multiplier=node_data.get('consumptionMultiplier', 1.0),
|
||||
cpu_count=node_data.get('cpuCount'),
|
||||
cpu_model=node_data.get('cpuModel'),
|
||||
total_ram=node_data.get('totalRam'),
|
||||
created_at=self._parse_optional_datetime(node_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(node_data.get('updatedAt')),
|
||||
provider_uuid=node_data.get('providerUuid')
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
|
||||
|
||||
@@ -6,6 +6,7 @@ from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import InterfaceError
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
|
||||
from app.config import settings
|
||||
@@ -17,6 +18,7 @@ from app.database.models import (
|
||||
SubscriptionStatus,
|
||||
BroadcastHistory,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
@@ -87,6 +89,56 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> O
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _persist_broadcast_result(
|
||||
db: AsyncSession,
|
||||
broadcast_history: BroadcastHistory,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
status: str,
|
||||
) -> None:
|
||||
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = status
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
return
|
||||
except InterfaceError as error:
|
||||
logger.warning(
|
||||
"Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз",
|
||||
exc_info=error,
|
||||
)
|
||||
await db.rollback()
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_session:
|
||||
retry_history = await retry_session.get(BroadcastHistory, broadcast_history.id)
|
||||
if not retry_history:
|
||||
logger.critical(
|
||||
"Не удалось найти запись BroadcastHistory #%s для повторной записи результатов",
|
||||
broadcast_history.id,
|
||||
)
|
||||
return
|
||||
|
||||
retry_history.sent_count = sent_count
|
||||
retry_history.failed_count = failed_count
|
||||
retry_history.status = status
|
||||
retry_history.completed_at = broadcast_history.completed_at
|
||||
await retry_session.commit()
|
||||
logger.info(
|
||||
"Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)",
|
||||
broadcast_history.id,
|
||||
)
|
||||
except Exception as retry_error:
|
||||
logger.critical(
|
||||
"Не удалось сохранить результаты рассылки после восстановления подключения",
|
||||
exc_info=retry_error,
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_menu(
|
||||
@@ -854,11 +906,14 @@ async def confirm_broadcast(
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = "completed" if failed_count == 0 else "partial"
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
status = "completed" if failed_count == 0 else "partial"
|
||||
await _persist_broadcast_result(
|
||||
db=db,
|
||||
broadcast_history=broadcast_history,
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
status=status,
|
||||
)
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
|
||||
@@ -134,6 +134,24 @@ CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
label_en="💳 Base subscription price",
|
||||
action="price",
|
||||
),
|
||||
SettingEntry(
|
||||
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED",
|
||||
section="core",
|
||||
label_ru="🎟️ Базовые скидки для групп",
|
||||
label_en="🎟️ Base group discounts",
|
||||
action="toggle",
|
||||
description_ru="Включает применение базовых скидок для групповых промо-периодов.",
|
||||
description_en="Enables base discounts for promo group periods.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS",
|
||||
section="core",
|
||||
label_ru="🔖 Скидки по периодам",
|
||||
label_en="🔖 Period discounts",
|
||||
action="input",
|
||||
description_ru="Формат: список пар дней и скидки через запятую (например 30:10,60:20).",
|
||||
description_en="Format: comma-separated day/discount pairs (e.g. 30:10,60:20).",
|
||||
),
|
||||
SettingEntry(
|
||||
key="DEFAULT_DEVICE_LIMIT",
|
||||
section="core",
|
||||
@@ -205,6 +223,26 @@ SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = {
|
||||
entry.key: entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
|
||||
}
|
||||
|
||||
SETTING_ENTRIES: Tuple[SettingEntry, ...] = tuple(
|
||||
entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
|
||||
)
|
||||
|
||||
SETTING_KEY_TO_TOKEN: Dict[str, str] = {
|
||||
entry.key: f"s{index}" for index, entry in enumerate(SETTING_ENTRIES)
|
||||
}
|
||||
|
||||
SETTING_TOKEN_TO_KEY: Dict[str, str] = {
|
||||
token: key for key, token in SETTING_KEY_TO_TOKEN.items()
|
||||
}
|
||||
|
||||
|
||||
def _encode_setting_callback_key(key: str) -> str:
|
||||
return SETTING_KEY_TO_TOKEN.get(key, key)
|
||||
|
||||
|
||||
def _decode_setting_callback_key(raw: str) -> str:
|
||||
return SETTING_TOKEN_TO_KEY.get(raw, raw)
|
||||
|
||||
|
||||
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
|
||||
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
|
||||
@@ -447,7 +485,9 @@ def _build_settings_section(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_pricing_toggle:{section}:{entry.key}",
|
||||
callback_data=(
|
||||
f"admin_pricing_toggle:{section}:{_encode_setting_callback_key(entry.key)}"
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -461,7 +501,7 @@ def _build_settings_section(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{icon} {option.label(lang_code)}",
|
||||
callback_data=(
|
||||
f"admin_pricing_choice:{section}:{entry.key}:{option.value}"
|
||||
f"admin_pricing_choice:{section}:{_encode_setting_callback_key(entry.key)}:{option.value}"
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -477,7 +517,9 @@ def _build_settings_section(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_pricing_setting:{section}:{entry.key}",
|
||||
callback_data=(
|
||||
f"admin_pricing_setting:{section}:{_encode_setting_callback_key(entry.key)}"
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -939,11 +981,12 @@ async def start_setting_edit(
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
try:
|
||||
_, section, key = callback.data.split(":", 2)
|
||||
_, section, raw_key = callback.data.split(":", 2)
|
||||
except ValueError:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
key = _decode_setting_callback_key(raw_key)
|
||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||
texts = get_texts(db_user.language)
|
||||
lang_code = _language_code(db_user.language)
|
||||
@@ -1140,11 +1183,12 @@ async def toggle_setting(
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
try:
|
||||
_, section, key = callback.data.split(":", 2)
|
||||
_, section, raw_key = callback.data.split(":", 2)
|
||||
except ValueError:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
key = _decode_setting_callback_key(raw_key)
|
||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||
if not entry or entry.action != "toggle":
|
||||
await callback.answer()
|
||||
@@ -1171,11 +1215,12 @@ async def select_setting_choice(
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
try:
|
||||
_, section, key, value_raw = callback.data.split(":", 3)
|
||||
_, section, raw_key, value_raw = callback.data.split(":", 3)
|
||||
except ValueError:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
key = _decode_setting_callback_key(raw_key)
|
||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||
if not entry or entry.action != "choice" or not entry.choices:
|
||||
await callback.answer()
|
||||
|
||||
+167
-56
@@ -1316,7 +1316,29 @@ async def show_node_details(
|
||||
|
||||
status_emoji = "🟢" if node["is_node_online"] else "🔴"
|
||||
xray_emoji = "✅" if node["is_xray_running"] else "❌"
|
||||
|
||||
|
||||
status_change = (
|
||||
format_datetime(node["last_status_change"])
|
||||
if node.get("last_status_change")
|
||||
else "—"
|
||||
)
|
||||
created_at = (
|
||||
format_datetime(node["created_at"])
|
||||
if node.get("created_at")
|
||||
else "—"
|
||||
)
|
||||
updated_at = (
|
||||
format_datetime(node["updated_at"])
|
||||
if node.get("updated_at")
|
||||
else "—"
|
||||
)
|
||||
notify_percent = (
|
||||
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—"
|
||||
)
|
||||
cpu_info = node.get("cpu_model") or "—"
|
||||
if node.get("cpu_count"):
|
||||
cpu_info = f"{node['cpu_count']}x {cpu_info}"
|
||||
|
||||
text = f"""
|
||||
🖥️ <b>Нода: {node['name']}</b>
|
||||
|
||||
@@ -1325,15 +1347,29 @@ async def show_node_details(
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'}
|
||||
- Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'}
|
||||
- Изменение статуса: {status_change}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
|
||||
<b>Информация:</b>
|
||||
- Адрес: {node['address']}
|
||||
- Страна: {node['country_code']}
|
||||
- Пользователей онлайн: {node['users_online']}
|
||||
- CPU: {cpu_info}
|
||||
- RAM: {node.get('total_ram') or '—'}
|
||||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'])}
|
||||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||||
- Уведомления: {notify_percent}
|
||||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||||
|
||||
<b>Метаданные:</b>
|
||||
- Создана: {created_at}
|
||||
- Обновлена: {updated_at}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -1350,28 +1386,18 @@ async def manage_node(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
success = await remnawave_service.manage_node(node_uuid, action)
|
||||
|
||||
if success:
|
||||
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
|
||||
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
|
||||
else:
|
||||
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
|
||||
|
||||
await show_node_details(
|
||||
types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"admin_node_manage_{node_uuid}",
|
||||
message=callback.message
|
||||
),
|
||||
db_user,
|
||||
db
|
||||
)
|
||||
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
success = await remnawave_service.manage_node(node_uuid, action)
|
||||
|
||||
if success:
|
||||
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
|
||||
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
|
||||
else:
|
||||
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
|
||||
|
||||
await show_node_details(callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -1407,10 +1433,32 @@ async def show_node_statistics(
|
||||
if stats.get('nodeUuid') == node_uuid:
|
||||
node_realtime = stats
|
||||
break
|
||||
|
||||
|
||||
status_change = (
|
||||
format_datetime(node["last_status_change"])
|
||||
if node.get("last_status_change")
|
||||
else "—"
|
||||
)
|
||||
created_at = (
|
||||
format_datetime(node["created_at"])
|
||||
if node.get("created_at")
|
||||
else "—"
|
||||
)
|
||||
updated_at = (
|
||||
format_datetime(node["updated_at"])
|
||||
if node.get("updated_at")
|
||||
else "—"
|
||||
)
|
||||
notify_percent = (
|
||||
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—"
|
||||
)
|
||||
cpu_info = node.get("cpu_model") or "—"
|
||||
if node.get("cpu_count"):
|
||||
cpu_info = f"{node['cpu_count']}x {cpu_info}"
|
||||
|
||||
status_emoji = "🟢" if node["is_node_online"] else "🔴"
|
||||
xray_emoji = "✅" if node["is_xray_running"] else "❌"
|
||||
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика ноды: {node['name']}</b>
|
||||
|
||||
@@ -1418,10 +1466,26 @@ async def show_node_statistics(
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Пользователей онлайн: {node['users_online'] or 0}
|
||||
- Изменение статуса: {status_change}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
|
||||
<b>Ресурсы:</b>
|
||||
- CPU: {cpu_info}
|
||||
- RAM: {node.get('total_ram') or '—'}
|
||||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||||
- Уведомления: {notify_percent}
|
||||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||||
|
||||
<b>Метаданные:</b>
|
||||
- Создана: {created_at}
|
||||
- Обновлена: {updated_at}
|
||||
"""
|
||||
|
||||
if node_realtime:
|
||||
@@ -1456,18 +1520,25 @@ async def show_node_statistics(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}")
|
||||
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика ноды: {node['name']}</b>
|
||||
|
||||
<b>Статус:</b>
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Пользователей онлайн: {node['users_online'] or 0}
|
||||
- Изменение статуса: {format_datetime(node.get('last_status_change')) if node.get('last_status_change') else '—'}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||||
- Уведомления: {node.get('notify_percent') or '—'}
|
||||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||||
|
||||
⚠️ <b>Детальная статистика временно недоступна</b>
|
||||
Возможные причины:
|
||||
@@ -1566,17 +1637,11 @@ async def manage_squad_action(
|
||||
await callback.answer("❌ Ошибка удаления сквада", show_alert=True)
|
||||
return
|
||||
|
||||
await show_squad_details(
|
||||
types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"admin_squad_manage_{squad_uuid}",
|
||||
message=callback.message
|
||||
),
|
||||
db_user,
|
||||
db
|
||||
)
|
||||
refreshed_callback = callback.model_copy(
|
||||
update={"data": f"admin_squad_manage_{squad_uuid}"}
|
||||
).as_(callback.bot)
|
||||
|
||||
await show_squad_details(refreshed_callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -1734,15 +1799,11 @@ async def cancel_squad_rename(
|
||||
|
||||
await state.clear()
|
||||
|
||||
new_callback = types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"squad_edit_{squad_uuid}",
|
||||
message=callback.message
|
||||
)
|
||||
|
||||
await show_squad_edit_menu(new_callback, db_user, db)
|
||||
refreshed_callback = callback.model_copy(
|
||||
update={"data": f"squad_edit_{squad_uuid}"}
|
||||
).as_(callback.bot)
|
||||
|
||||
await show_squad_edit_menu(refreshed_callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -1953,15 +2014,11 @@ async def show_squad_edit_menu_short(
|
||||
await callback.answer("❌ Сквад не найден", show_alert=True)
|
||||
return
|
||||
|
||||
new_callback = types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"squad_edit_{full_squad_uuid}",
|
||||
message=callback.message
|
||||
)
|
||||
|
||||
await show_squad_edit_menu(new_callback, db_user, db)
|
||||
refreshed_callback = callback.model_copy(
|
||||
update={"data": f"squad_edit_{full_squad_uuid}"}
|
||||
).as_(callback.bot)
|
||||
|
||||
await show_squad_edit_menu(refreshed_callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -2285,6 +2342,9 @@ async def show_sync_options(
|
||||
"• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n"
|
||||
"• Рекомендуется делать полную синхронизацию ежедневно\n"
|
||||
"• Баланс пользователей НЕ удаляется\n\n"
|
||||
"⬆️ <b>Обратная синхронизация:</b>\n"
|
||||
"• Отправляет активных пользователей из бота в панель\n"
|
||||
"• Используйте при сбоях панели или для восстановления данных\n\n"
|
||||
+ "\n".join(status_lines)
|
||||
)
|
||||
|
||||
@@ -2295,6 +2355,12 @@ async def show_sync_options(
|
||||
callback_data="sync_all_users",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬆️ Синхронизация в панель",
|
||||
callback_data="sync_to_panel",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⚙️ Настройки автосинхронизации",
|
||||
@@ -2654,6 +2720,50 @@ async def sync_all_users(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_users_to_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"⬆️ Выполняется синхронизация данных бота в панель Remnawave...\n\n"
|
||||
"Это может занять несколько минут.",
|
||||
reply_markup=None,
|
||||
)
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
stats = await remnawave_service.sync_users_to_panel(db)
|
||||
|
||||
if stats["errors"] == 0:
|
||||
status_emoji = "✅"
|
||||
status_text = "успешно завершена"
|
||||
else:
|
||||
status_emoji = "⚠️" if (stats["created"] + stats["updated"]) > 0 else "❌"
|
||||
status_text = "завершена с предупреждениями" if status_emoji == "⚠️" else "завершена с ошибками"
|
||||
|
||||
text = (
|
||||
f"{status_emoji} <b>Синхронизация в панель {status_text}</b>\n\n"
|
||||
"📊 <b>Результаты:</b>\n"
|
||||
f"• 🆕 Создано: {stats['created']}\n"
|
||||
f"• 🔄 Обновлено: {stats['updated']}\n"
|
||||
f"• ❌ Ошибок: {stats['errors']}"
|
||||
)
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="sync_to_panel")],
|
||||
[types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
|
||||
[types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_sync_recommendations(
|
||||
@@ -3126,6 +3236,7 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(cancel_auto_sync_schedule, F.data == "remnawave_auto_sync_cancel")
|
||||
dp.callback_query.register(run_auto_sync_now, F.data == "remnawave_auto_sync_run")
|
||||
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
|
||||
dp.callback_query.register(sync_users_to_panel, F.data == "sync_to_panel")
|
||||
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
|
||||
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
|
||||
dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_"))
|
||||
|
||||
@@ -12,6 +12,11 @@ from app.database.crud.user_message import (
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.utils.validators import (
|
||||
get_html_help_text,
|
||||
sanitize_html,
|
||||
validate_html_tags,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -122,8 +127,6 @@ async def add_user_message_start(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.validators import get_html_help_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Добавление нового сообщения</b>\n\n"
|
||||
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
|
||||
@@ -161,8 +164,6 @@ async def process_new_message_text(
|
||||
)
|
||||
return
|
||||
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
is_valid, error_msg = validate_html_tags(message_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
@@ -312,20 +313,22 @@ async def view_user_message(
|
||||
return
|
||||
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
safe_content = sanitize_html(message.message_text)
|
||||
|
||||
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
|
||||
|
||||
|
||||
text = (
|
||||
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
|
||||
f"<b>Статус:</b> {status_text}\n"
|
||||
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Содержимое:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>"
|
||||
f"<blockquote>{safe_content}</blockquote>"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -455,7 +458,7 @@ async def edit_user_message_start(
|
||||
await callback.message.edit_text(
|
||||
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
|
||||
f"<b>Текущий текст:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>\n\n"
|
||||
f"<blockquote>{sanitize_html(message.message_text)}</blockquote>\n\n"
|
||||
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -489,14 +492,23 @@ async def process_edit_message_text(
|
||||
return
|
||||
|
||||
new_text = message.text.strip()
|
||||
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
||||
"Попробуйте еще раз или отправьте /cancel для отмены."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
is_valid, error_msg = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
|
||||
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
|
||||
parse_mode=None
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
updated_message = await update_user_message(
|
||||
db=db,
|
||||
@@ -511,7 +523,7 @@ async def process_edit_message_text(
|
||||
f"<b>ID:</b> {updated_message.id}\n"
|
||||
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Новый текст:</b>\n"
|
||||
f"<blockquote>{new_text}</blockquote>",
|
||||
f"<blockquote>{sanitize_html(new_text)}</blockquote>",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
+301
-2
@@ -32,6 +32,7 @@ from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.utils.user_utils import get_effective_referral_commission_percent
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
from app.database.crud.server_squad import (
|
||||
@@ -1536,6 +1537,9 @@ async def _build_user_referrals_view(
|
||||
|
||||
referrals = await get_referrals(db, user_id)
|
||||
|
||||
effective_percent = get_effective_referral_commission_percent(user)
|
||||
default_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_USER_REFERRALS_TITLE",
|
||||
"🤝 <b>Рефералы пользователя</b>",
|
||||
@@ -1551,6 +1555,24 @@ async def _build_user_referrals_view(
|
||||
|
||||
lines: List[str] = [header, summary]
|
||||
|
||||
if user.referral_commission_percent is None:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT",
|
||||
"• Процент комиссии: {percent}% (стандартное значение)",
|
||||
).format(percent=effective_percent)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM",
|
||||
"• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)",
|
||||
).format(
|
||||
percent=user.referral_commission_percent,
|
||||
default_percent=default_percent,
|
||||
)
|
||||
)
|
||||
|
||||
if referrals:
|
||||
lines.append(
|
||||
texts.t(
|
||||
@@ -1604,6 +1626,15 @@ async def _build_user_referrals_view(
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON",
|
||||
"📈 Изменить процент",
|
||||
),
|
||||
callback_data=f"admin_user_referral_percent_{user_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
@@ -1636,12 +1667,12 @@ async def show_user_referrals(
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.editing_user_referrals:
|
||||
if current_state in {AdminStates.editing_user_referrals, AdminStates.editing_user_referral_percent}:
|
||||
data = await state.get_data()
|
||||
preserved_data = {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if key not in {"editing_referrals_user_id", "referrals_message_id"}
|
||||
if key not in {"editing_referrals_user_id", "referrals_message_id", "editing_referral_percent_user_id"}
|
||||
}
|
||||
await state.clear()
|
||||
if preserved_data:
|
||||
@@ -1661,6 +1692,256 @@ async def show_user_referrals(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_referral_percent(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
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
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
effective_percent = get_effective_referral_commission_percent(user)
|
||||
default_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT",
|
||||
(
|
||||
"📈 <b>Индивидуальный процент реферальной комиссии</b>\n\n"
|
||||
"Текущее значение: {current}%\n"
|
||||
"Стандартное значение: {default}%\n\n"
|
||||
"Отправьте новое значение от 0 до 100 или слово 'стандарт' для сброса."
|
||||
),
|
||||
).format(current=effective_percent, default=default_percent)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="5%",
|
||||
callback_data=f"admin_user_referral_percent_set_{user_id}_5",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="10%",
|
||||
callback_data=f"admin_user_referral_percent_set_{user_id}_10",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="15%",
|
||||
callback_data=f"admin_user_referral_percent_set_{user_id}_15",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="20%",
|
||||
callback_data=f"admin_user_referral_percent_set_{user_id}_20",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON",
|
||||
"♻️ Сбросить на стандартный",
|
||||
),
|
||||
callback_data=f"admin_user_referral_percent_reset_{user_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_referrals_{user_id}",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await state.update_data(editing_referral_percent_user_id=user_id)
|
||||
await state.set_state(AdminStates.editing_user_referral_percent)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _update_referral_commission_percent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
percent: Optional[int],
|
||||
admin_id: int,
|
||||
) -> Tuple[bool, Optional[int]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, None
|
||||
|
||||
user.referral_commission_percent = percent
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
effective = get_effective_referral_commission_percent(user)
|
||||
|
||||
logger.info(
|
||||
"Админ %s обновил реферальный процент пользователя %s: %s",
|
||||
admin_id,
|
||||
user_id,
|
||||
percent,
|
||||
)
|
||||
|
||||
return True, effective
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка обновления реферального процента пользователя %s: %s",
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error:
|
||||
logger.error("Ошибка отката транзакции: %s", rollback_error)
|
||||
return False, None
|
||||
|
||||
|
||||
async def _render_referrals_after_update(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
db_user: User,
|
||||
user_id: int,
|
||||
success_message: str,
|
||||
):
|
||||
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||
if view:
|
||||
text, keyboard = view
|
||||
text = f"{success_message}\n\n" + text
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
else:
|
||||
await callback.message.edit_text(success_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_referral_percent_button(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
|
||||
if "reset" in parts:
|
||||
user_id = int(parts[-1])
|
||||
percent_value: Optional[int] = None
|
||||
else:
|
||||
user_id = int(parts[-2])
|
||||
percent_value = int(parts[-1])
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
success, effective_percent = await _update_referral_commission_percent(
|
||||
db,
|
||||
user_id,
|
||||
percent_value,
|
||||
db_user.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Не удалось обновить процент", show_alert=True)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
success_message = texts.t(
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED",
|
||||
"✅ Процент обновлён: {percent}%",
|
||||
).format(percent=effective_percent)
|
||||
|
||||
await _render_referrals_after_update(callback, db, db_user, user_id, success_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_referral_percent_input(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
user_id = data.get("editing_referral_percent_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer("❌ Не удалось определить пользователя")
|
||||
return
|
||||
|
||||
raw_text = message.text.strip()
|
||||
normalized = raw_text.lower()
|
||||
|
||||
percent_value: Optional[int]
|
||||
|
||||
if normalized in {"стандарт", "standard", "default"}:
|
||||
percent_value = None
|
||||
else:
|
||||
normalized_number = raw_text.replace(',', '.').strip()
|
||||
try:
|
||||
percent_float = float(normalized_number)
|
||||
except (TypeError, ValueError):
|
||||
await message.answer(
|
||||
get_texts(db_user.language).t(
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_INVALID",
|
||||
"❌ Введите число от 0 до 100 или слово 'стандарт'",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
percent_value = int(round(percent_float))
|
||||
|
||||
if percent_value < 0 or percent_value > 100:
|
||||
await message.answer(
|
||||
get_texts(db_user.language).t(
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_INVALID",
|
||||
"❌ Введите число от 0 до 100 или слово 'стандарт'",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
success, effective_percent = await _update_referral_commission_percent(
|
||||
db,
|
||||
int(user_id),
|
||||
percent_value,
|
||||
db_user.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await message.answer("❌ Не удалось обновить процент")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
success_message = texts.t(
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED",
|
||||
"✅ Процент обновлён: {percent}%",
|
||||
).format(percent=effective_percent)
|
||||
|
||||
view = await _build_user_referrals_view(db, db_user.language, int(user_id))
|
||||
if view:
|
||||
text, keyboard = view
|
||||
await message.answer(f"{success_message}\n\n{text}", reply_markup=keyboard)
|
||||
else:
|
||||
await message.answer(success_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_user_referrals(
|
||||
@@ -4621,6 +4902,24 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_edit_referral_percent,
|
||||
F.data.startswith("admin_user_referral_percent_")
|
||||
& ~F.data.contains("_set_")
|
||||
& ~F.data.contains("_reset")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_referral_percent_button,
|
||||
F.data.startswith("admin_user_referral_percent_set_")
|
||||
| F.data.startswith("admin_user_referral_percent_reset_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_referral_percent_input,
|
||||
AdminStates.editing_user_referral_percent,
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_edit_user_referrals,
|
||||
F.data.startswith("admin_user_referrals_edit_")
|
||||
|
||||
@@ -94,15 +94,38 @@ async def show_balance_menu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
balance_text = texts.BALANCE_INFO.format(
|
||||
balance=texts.format_price(db_user.balance_kopeks)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=get_balance_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
reply_markup = get_balance_keyboard(db_user.language)
|
||||
|
||||
try:
|
||||
if callback.message and callback.message.text:
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
elif callback.message and callback.message.caption:
|
||||
await callback.message.edit_caption(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
else:
|
||||
await callback.message.answer(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
logger.warning(
|
||||
"Failed to edit balance message, sending a new one instead: %s",
|
||||
error,
|
||||
)
|
||||
await callback.message.answer(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -421,7 +444,17 @@ async def request_support_topup(
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.is_support_topup_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"SUPPORT_TOPUP_DISABLED",
|
||||
"Пополнение через поддержку отключено. Попробуйте другой способ оплаты.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
support_text = f"""
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
|
||||
+134
-2
@@ -149,6 +149,18 @@ async def show_main_menu(
|
||||
*,
|
||||
skip_callback_answer: bool = False,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
@@ -164,7 +176,7 @@ async def show_main_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
@@ -230,6 +242,18 @@ async def show_service_rules(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -252,6 +276,18 @@ async def show_info_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
header = texts.t("MENU_INFO_HEADER", "ℹ️ <b>Инфо</b>")
|
||||
@@ -283,6 +319,18 @@ async def show_promo_groups_info(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
promo_groups = await get_auto_assign_promo_groups(db)
|
||||
@@ -423,6 +471,18 @@ async def show_faq_pages(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
pages = await FaqService.get_pages(db, db_user.language)
|
||||
@@ -467,6 +527,18 @@ async def show_faq_page(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_data = callback.data or ""
|
||||
@@ -590,6 +662,18 @@ async def show_privacy_policy(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
@@ -697,6 +781,18 @@ async def show_public_offer(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
@@ -804,6 +900,18 @@ async def show_language_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
@@ -834,6 +942,18 @@ async def process_language_change(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
@@ -889,6 +1009,18 @@ async def handle_back_to_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -903,7 +1035,7 @@ async def handle_back_to_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
|
||||
@@ -59,6 +59,8 @@ async def activate_promocode_for_registration(
|
||||
user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
result.get("balance_before_kopeks"),
|
||||
result.get("balance_after_kopeks"),
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.localization.texts import get_texts
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_effective_referral_commission_percent,
|
||||
get_referral_analytics,
|
||||
get_user_referral_summary,
|
||||
)
|
||||
@@ -86,7 +87,7 @@ async def show_referral_info(
|
||||
+ texts.t(
|
||||
"REFERRAL_REWARD_COMMISSION",
|
||||
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
|
||||
).format(percent=get_effective_referral_commission_percent(db_user))
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
|
||||
+ f"\n<code>{referral_link}</code>\n\n"
|
||||
|
||||
@@ -134,8 +134,11 @@ async def start_simple_subscription_purchase(
|
||||
if show_devices:
|
||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
traffic_limit_gb = subscription_params["traffic_limit_gb"]
|
||||
traffic_label = "Безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ"
|
||||
|
||||
message_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"📊 Трафик: {traffic_label}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||
@@ -523,8 +526,11 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
if show_devices:
|
||||
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
success_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||
success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ"
|
||||
|
||||
success_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"📊 Трафик: {success_traffic_label}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
|
||||
@@ -721,8 +727,11 @@ async def handle_simple_subscription_other_payment_methods(
|
||||
if show_devices:
|
||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
payment_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||
payment_traffic_label = "Безлимит" if payment_traffic_gb == 0 else f"{payment_traffic_gb} ГБ"
|
||||
|
||||
message_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"📊 Трафик: {payment_traffic_label}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||
@@ -826,6 +835,9 @@ async def handle_simple_subscription_payment_method(
|
||||
|
||||
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
|
||||
|
||||
stars_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||
stars_traffic_label = "Безлимит" if stars_traffic_gb == 0 else f"{stars_traffic_gb} ГБ"
|
||||
|
||||
await callback.bot.send_invoice(
|
||||
chat_id=callback.from_user.id,
|
||||
title=f"Подписка на {subscription_params['period_days']} дней",
|
||||
@@ -833,7 +845,7 @@ async def handle_simple_subscription_payment_method(
|
||||
f"Простая покупка подписки\n"
|
||||
f"Период: {subscription_params['period_days']} дней\n"
|
||||
f"Устройства: {subscription_params['device_limit']}\n"
|
||||
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
|
||||
f"Трафик: {stars_traffic_label}"
|
||||
),
|
||||
payload=(
|
||||
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
|
||||
@@ -977,8 +989,11 @@ async def handle_simple_subscription_payment_method(
|
||||
if show_devices:
|
||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
yookassa_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||
yookassa_traffic_label = "Безлимит" if yookassa_traffic_gb == 0 else f"{yookassa_traffic_gb} ГБ"
|
||||
|
||||
message_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"📊 Трафик: {yookassa_traffic_label}",
|
||||
f"💰 Сумма: {settings.format_price(price_kopeks)}",
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
|
||||
"",
|
||||
@@ -2220,8 +2235,11 @@ async def confirm_simple_subscription_purchase(
|
||||
if show_devices:
|
||||
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
success_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||
success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ"
|
||||
|
||||
success_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"📊 Трафик: {success_traffic_label}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
|
||||
|
||||
+244
-50
@@ -20,18 +20,20 @@ from app.database.crud.campaign import (
|
||||
from app.database.models import UserStatus, SubscriptionStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard,
|
||||
get_privacy_policy_keyboard,
|
||||
get_main_menu_keyboard,
|
||||
get_post_registration_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
)
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.localization.texts import get_texts, get_rules, get_privacy_policy
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
@@ -104,6 +106,7 @@ async def handle_potential_referral_code(
|
||||
|
||||
if current_state not in [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_privacy_policy_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state,
|
||||
None
|
||||
]:
|
||||
@@ -611,12 +614,105 @@ async def process_language_selection(
|
||||
)
|
||||
|
||||
|
||||
async def _show_privacy_policy_after_rules(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Показывает политику конфиденциальности после принятия правил.
|
||||
Возвращает True, если политика была показана, False если её нет или произошла ошибка.
|
||||
"""
|
||||
policy = await PrivacyPolicyService.get_policy(db, language, fallback=True)
|
||||
|
||||
if not policy or not policy.is_enabled:
|
||||
logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ")
|
||||
return False
|
||||
|
||||
if not policy.content or not policy.content.strip():
|
||||
privacy_policy_text = get_privacy_policy(language)
|
||||
if not privacy_policy_text or not privacy_policy_text.strip():
|
||||
logger.info("⚠️ Политика конфиденциальности включена, но дефолтный текст пустой, пропускаем показ")
|
||||
return False
|
||||
logger.info(f"🔒 Используется дефолтный текст политики конфиденциальности из локализации для языка {language}")
|
||||
else:
|
||||
privacy_policy_text = policy.content
|
||||
logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}")
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_text,
|
||||
reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
logger.info(f"🔒 Политика конфиденциальности отправлена пользователю {callback.from_user.id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе политики конфиденциальности: {e}", exc_info=True)
|
||||
try:
|
||||
await callback.message.answer(
|
||||
privacy_policy_text,
|
||||
reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
logger.info(f"🔒 Политика конфиденциальности отправлена новым сообщением пользователю {callback.from_user.id}")
|
||||
return True
|
||||
except Exception as e2:
|
||||
logger.error(f"Критическая ошибка при отправке политики конфиденциальности: {e2}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def _continue_registration_after_rules(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> None:
|
||||
"""
|
||||
Продолжает регистрацию после принятия правил (реферальный код или завершение).
|
||||
"""
|
||||
data = await state.get_data() or {}
|
||||
texts = get_texts(language)
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
if settings.SKIP_REFERRAL_CODE:
|
||||
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
|
||||
async def process_rules_accept(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
"""
|
||||
Обрабатывает принятие или отклонение правил пользователем.
|
||||
"""
|
||||
logger.info(f"📋 RULES: Начало обработки правил")
|
||||
logger.info(f"📊 Callback data: {callback.data}")
|
||||
logger.info(f"👤 User: {callback.from_user.id}")
|
||||
@@ -637,50 +733,16 @@ async def process_rules_accept(
|
||||
if callback.data == 'rules_accept':
|
||||
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с правилами удалено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"RULES_ACCEPTED_PROCESSING",
|
||||
"✅ Правила приняты! Завершаем регистрацию...",
|
||||
),
|
||||
reply_markup=None
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Пытаемся показать политику конфиденциальности
|
||||
policy_shown = await _show_privacy_policy_after_rules(
|
||||
callback, state, db, language
|
||||
)
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
if settings.SKIP_REFERRAL_CODE:
|
||||
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
# Если политика не была показана, продолжаем регистрацию
|
||||
if not policy_shown:
|
||||
await _continue_registration_after_rules(
|
||||
callback, state, db, language
|
||||
)
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||
@@ -697,10 +759,13 @@ async def process_rules_accept(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
rules_required_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
rules_required_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
|
||||
|
||||
@@ -727,6 +792,128 @@ async def process_rules_accept(
|
||||
pass
|
||||
|
||||
|
||||
async def process_privacy_policy_accept(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности")
|
||||
logger.info(f"📊 Callback data: {callback.data}")
|
||||
logger.info(f"👤 User: {callback.from_user.id}")
|
||||
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
try:
|
||||
await callback.answer()
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', language)
|
||||
texts = get_texts(language)
|
||||
|
||||
if callback.data == 'privacy_policy_accept':
|
||||
logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}")
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с политикой конфиденциальности: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PRIVACY_POLICY_ACCEPTED_PROCESSING",
|
||||
"✅ Политика конфиденциальности принята! Продолжаем регистрацию...",
|
||||
),
|
||||
reply_markup=None
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
if settings.SKIP_REFERRAL_CODE:
|
||||
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await state.set_data(data)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
|
||||
await callback.bot.send_message(
|
||||
chat_id=callback.from_user.id,
|
||||
text=texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}")
|
||||
|
||||
privacy_policy_required_text = texts.t(
|
||||
"PRIVACY_POLICY_REQUIRED",
|
||||
"Для использования бота необходимо принять политику конфиденциальности.",
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_required_text,
|
||||
reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
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.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True)
|
||||
await callback.answer(
|
||||
texts.t("ERROR_TRY_AGAIN", "❌ Произошла ошибка. Попробуйте еще раз."),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
try:
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', language)
|
||||
texts = get_texts(language)
|
||||
await callback.message.answer(
|
||||
texts.t(
|
||||
"ERROR_PRIVACY_POLICY_RETRY",
|
||||
"Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:",
|
||||
),
|
||||
reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def process_referral_code_input(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
@@ -1751,7 +1938,14 @@ def register_handlers(dp: Dispatcher):
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_privacy_policy_accept,
|
||||
F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]),
|
||||
StateFilter(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_privacy_policy_accept")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_selection,
|
||||
F.data.startswith("language_select:"),
|
||||
|
||||
@@ -406,6 +406,17 @@ async def apply_countries_changes(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
|
||||
if len(selected_countries) == 0:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if added and total_cost > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -898,6 +909,17 @@ async def confirm_add_countries_to_subscription(
|
||||
return
|
||||
|
||||
try:
|
||||
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
|
||||
if len(selected_countries) == 0:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
if new_countries and total_price > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, total_price,
|
||||
|
||||
@@ -1760,9 +1760,11 @@ async def select_devices(
|
||||
)
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
|
||||
selected_countries = data.get('countries', [])
|
||||
countries_price = sum(
|
||||
c['price_kopeks'] for c in countries
|
||||
if c['uuid'] in data['countries']
|
||||
if c['uuid'] in selected_countries
|
||||
)
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
@@ -1821,9 +1823,15 @@ async def confirm_purchase(
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
|
||||
months_in_period = data.get(
|
||||
'months_in_period', calculate_months_from_days(data['period_days'])
|
||||
)
|
||||
period_days = data.get('period_days')
|
||||
if period_days is None:
|
||||
await callback.message.edit_text(
|
||||
texts.t("SUBSCRIPTION_PURCHASE_ERROR", "Ошибка при оформлении подписки. Попробуйте начать сначала."),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
months_in_period = data.get('months_in_period', calculate_months_from_days(period_days))
|
||||
|
||||
base_price = data.get('base_price')
|
||||
base_price_original = data.get('base_price_original')
|
||||
@@ -1831,10 +1839,10 @@ async def confirm_purchase(
|
||||
base_discount_total = data.get('base_discount_total')
|
||||
|
||||
if base_price is None:
|
||||
base_price_original = PERIOD_PRICES[data['period_days']]
|
||||
base_price_original = PERIOD_PRICES[period_days]
|
||||
base_discount_percent = db_user.get_promo_discount(
|
||||
"period",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
@@ -1842,11 +1850,11 @@ async def confirm_purchase(
|
||||
)
|
||||
else:
|
||||
if base_price_original is None:
|
||||
base_price_original = PERIOD_PRICES[data['period_days']]
|
||||
base_price_original = PERIOD_PRICES[period_days]
|
||||
if base_discount_percent is None:
|
||||
base_discount_percent = db_user.get_promo_discount(
|
||||
"period",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
if base_discount_total is None:
|
||||
_, base_discount_total = apply_percentage_discount(
|
||||
@@ -1859,14 +1867,16 @@ async def confirm_purchase(
|
||||
countries_price_per_month = 0
|
||||
per_month_prices: List[int] = []
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
|
||||
selected_countries = data.get('countries', [])
|
||||
if country['uuid'] in selected_countries:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
per_month_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
"servers",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
total_servers_price = 0
|
||||
total_servers_discount = 0
|
||||
@@ -1928,7 +1938,7 @@ async def confirm_purchase(
|
||||
else:
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
@@ -1944,9 +1954,15 @@ async def confirm_purchase(
|
||||
)
|
||||
else:
|
||||
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', settings.get_traffic_price(data['traffic_gb'])
|
||||
)
|
||||
traffic_gb = data.get('traffic_gb')
|
||||
if traffic_gb is not None:
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', settings.get_traffic_price(traffic_gb)
|
||||
)
|
||||
else:
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', 0
|
||||
)
|
||||
|
||||
if 'traffic_discount_percent' in data:
|
||||
traffic_discount_percent = data.get('traffic_discount_percent', 0)
|
||||
@@ -1960,7 +1976,7 @@ async def confirm_purchase(
|
||||
else:
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
"traffic",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
@@ -1971,7 +1987,7 @@ async def confirm_purchase(
|
||||
|
||||
total_servers_price = data.get('total_servers_price', total_countries_price)
|
||||
|
||||
cached_total_price = data['total_price']
|
||||
cached_total_price = data.get('total_price', 0)
|
||||
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
|
||||
|
||||
validation_total_price = data.get('total_price_before_promo_offer')
|
||||
@@ -2181,10 +2197,10 @@ async def confirm_purchase(
|
||||
trial_duration_days=trial_duration,
|
||||
payment_method="balance",
|
||||
first_payment_amount_kopeks=final_price,
|
||||
first_paid_period_days=data['period_days']
|
||||
first_paid_period_days=period_days
|
||||
)
|
||||
logger.info(
|
||||
f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price / 100}₽")
|
||||
f"Записана конверсия: {trial_duration} дн. триал → {period_days} дн. платная за {final_price / 100}₽")
|
||||
except Exception as conversion_error:
|
||||
logger.error(f"Ошибка записи конверсии: {conversion_error}")
|
||||
|
||||
@@ -2193,10 +2209,37 @@ async def confirm_purchase(
|
||||
existing_subscription.traffic_limit_gb = final_traffic_gb
|
||||
if should_update_devices:
|
||||
existing_subscription.device_limit = selected_devices
|
||||
existing_subscription.connected_squads = data['countries']
|
||||
# Проверяем, что при обновлении существующей подписки есть хотя бы одна страна
|
||||
selected_countries = data.get('countries', [])
|
||||
if not selected_countries:
|
||||
# В случае если подписка уже существовала, не разрешаем отключать все страны
|
||||
# Если подписка новая, разрешаем, но обычно через UI пользователь должен выбрать хотя бы один сервер
|
||||
if existing_subscription and existing_subscription.connected_squads is not None:
|
||||
# Проверим, что в данных есть информация о том, что это обновление существующей подписки
|
||||
# или что-то указывает, что не нужно отключать все страны
|
||||
pass # Для простоты в этом случае просто проверим, что список стран не пустой
|
||||
else:
|
||||
# Для новой подписки разрешаем пустой список, если не является обновлением
|
||||
pass
|
||||
|
||||
# Но для безопасности - если список стран пустой, проверим, что это разрешено
|
||||
# иначе вернем ошибку
|
||||
if not selected_countries:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
existing_subscription.connected_squads = selected_countries
|
||||
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period
|
||||
existing_subscription.end_date = current_time + timedelta(days=period_days) + bonus_period
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
@@ -2222,12 +2265,29 @@ async def confirm_purchase(
|
||||
if resolved_device_limit is None and devices_selection_enabled:
|
||||
resolved_device_limit = default_device_limit
|
||||
|
||||
# Проверяем, что для новой подписки также есть хотя бы одна страна, если пользователь проходит через интерфейс стран
|
||||
new_subscription_countries = data.get('countries', [])
|
||||
if not new_subscription_countries:
|
||||
# Проверяем, была ли это покупка через интерфейс стран, и если да, то требуем хотя бы одну страну
|
||||
# Если в данных явно указано, что это интерфейс стран, или есть другие признаки - требуем страну
|
||||
# Для упрощения - проверим, что страна обязательна, если идет через UI стран
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
subscription = await create_paid_subscription_with_traffic_mode(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
duration_days=data['period_days'],
|
||||
duration_days=period_days,
|
||||
device_limit=resolved_device_limit,
|
||||
connected_squads=data['countries'],
|
||||
connected_squads=new_subscription_countries,
|
||||
traffic_gb=final_traffic_gb
|
||||
)
|
||||
|
||||
@@ -2237,7 +2297,7 @@ async def confirm_purchase(
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, data['countries'])
|
||||
server_ids = await get_server_ids_by_uuids(db, data.get('countries', []))
|
||||
|
||||
if server_ids:
|
||||
await add_subscription_servers(db, subscription, server_ids, server_prices)
|
||||
@@ -2278,13 +2338,13 @@ async def confirm_purchase(
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=final_price,
|
||||
description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)"
|
||||
description=f"Подписка на {period_days} дней ({months_in_period} мес)"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db, db_user, subscription, transaction, data['period_days'], was_trial_conversion
|
||||
db, db_user, subscription, transaction, period_days, was_trial_conversion
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
|
||||
|
||||
@@ -1118,6 +1118,12 @@ def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="sync_all_users"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SYNC_TO_PANEL", "⬆️ Синхронизация в панель"),
|
||||
callback_data="sync_to_panel"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SYNC_ONLY_NEW", "🆕 Только новые"),
|
||||
|
||||
+42
-7
@@ -127,6 +127,21 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup
|
||||
]
|
||||
])
|
||||
|
||||
def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.PRIVACY_POLICY_ACCEPT,
|
||||
callback_data="privacy_policy_accept"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=texts.PRIVACY_POLICY_DECLINE,
|
||||
callback_data="privacy_policy_decline"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
def get_channel_sub_keyboard(
|
||||
channel_link: Optional[str],
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
@@ -743,6 +758,7 @@ def get_subscription_keyboard(
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
has_direct_payment_methods = False
|
||||
|
||||
if has_subscription:
|
||||
subscription_link = get_display_subscription_link(subscription) if subscription else None
|
||||
@@ -1131,6 +1147,7 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
|
||||
def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
has_direct_payment_methods = False
|
||||
|
||||
amount_kopeks = max(0, int(amount_kopeks or 0))
|
||||
|
||||
@@ -1146,6 +1163,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("stars")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
@@ -1155,6 +1173,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("yookassa_sbp"),
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1162,6 +1181,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("yookassa"),
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
keyboard.append([
|
||||
@@ -1170,6 +1190,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("tribute")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
@@ -1182,6 +1203,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("mulenpay")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
keyboard.append([
|
||||
@@ -1190,6 +1212,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("wata")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([
|
||||
@@ -1198,6 +1221,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("pal24")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
keyboard.append([
|
||||
@@ -1206,6 +1230,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("platega"),
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([
|
||||
@@ -1214,6 +1239,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("cryptobot")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_heleket_enabled():
|
||||
keyboard.append([
|
||||
@@ -1222,15 +1248,24 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("heleket")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
||||
callback_data="topup_support"
|
||||
)
|
||||
])
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
||||
callback_data="topup_support"
|
||||
)
|
||||
])
|
||||
|
||||
if len(keyboard) == 1:
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
|
||||
callback_data="payment_methods_unavailable"
|
||||
)
|
||||
])
|
||||
elif not has_direct_payment_methods and settings.is_support_topup_enabled():
|
||||
keyboard.insert(0, [
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
|
||||
|
||||
@@ -32,3 +32,12 @@ RULES_TEXT: |
|
||||
2. Do not distribute spam or malicious content.
|
||||
3. Respect other community members.
|
||||
|
||||
PAYMENT_METHODS_NONE_AVAILABLE: |
|
||||
💳 <b>Balance top-up methods</b>
|
||||
|
||||
⚠️ Payment methods are temporarily unavailable.
|
||||
Please try again later.
|
||||
|
||||
Choose a top-up option:
|
||||
SUPPORT_TOPUP_DISABLED: "Top-ups via support are disabled. Please use another payment method."
|
||||
|
||||
|
||||
@@ -32,3 +32,12 @@ RULES_TEXT: |
|
||||
2. Не распространяйте спам и вредоносный контент.
|
||||
3. Уважайте других пользователей.
|
||||
|
||||
PAYMENT_METHODS_NONE_AVAILABLE: |
|
||||
💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
⚠️ В данный момент способы оплаты временно недоступны.
|
||||
Попробуйте позже.
|
||||
|
||||
Выберите способ пополнения:
|
||||
SUPPORT_TOPUP_DISABLED: "Пополнение через поддержку отключено. Попробуйте другой способ оплаты."
|
||||
|
||||
|
||||
@@ -731,6 +731,22 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Referrals",
|
||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>User referrals</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Total referrals: {count}",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Commission percent: {percent}% (default)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Custom percent: {percent}% (default: {default_percent}%)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Change percent",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Custom referral commission</b>\n\nCurrent value: {current}%\nDefault value: {default}%\n\nSend a value from 0 to 100 or the word 'standard' to reset.",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Reset to default",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Percent updated: {percent}%",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Enter a number from 0 to 100 or the word 'standard'",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>List of referrals:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … and {count} more referrals",
|
||||
"ADMIN_USER_REFERRALS_EMPTY": "No referrals yet.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ To change the list, tap “✏️ Edit” below.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Edit",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
@@ -849,6 +865,7 @@
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Removed countries:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Reconnecting later will be charged",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
|
||||
"COUNTRIES_MINIMUM_REQUIRED": "❌ Cannot disconnect all countries. At least one country must remain connected.",
|
||||
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n➕ — will be added (paid)\n➖ — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Server management is unavailable — only one server is accessible",
|
||||
@@ -1070,6 +1087,8 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
||||
@@ -1214,8 +1233,14 @@
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
|
||||
"RULES_DECLINE": "❌ I do not accept",
|
||||
"RULES_HEADER": "📋 <b>Service Rules</b>",
|
||||
"PRIVACY_POLICY_ACCEPT": "✅ Accept",
|
||||
"PRIVACY_POLICY_DECLINE": "❌ Decline",
|
||||
"PRIVACY_POLICY_REQUIRED": "You must accept the privacy policy to use the bot.",
|
||||
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Privacy policy accepted! Continuing registration...",
|
||||
"ERROR_PRIVACY_POLICY_RETRY": "An error occurred. Please try accepting the privacy policy again:",
|
||||
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
|
||||
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Privacy Policy</b>\n\nWe are committed to protecting your privacy and personal data.\n\n<b>Data Collection:</b>\n• We collect only necessary information to provide services\n• Data is used exclusively for service operation\n\n<b>Data Protection:</b>\n• Your data is protected by modern encryption methods\n• We do not share data with third parties without your consent\n\n<b>Your Rights:</b>\n• You can request information about stored data\n• You can request deletion of your data\n\n<b>By using the service, you agree to the privacy policy.</b>",
|
||||
"SELECT_COUNTRIES": "Select countries:",
|
||||
"SELECT_DEVICES": "Number of devices:",
|
||||
"SELECT_PERIOD": "Choose period:",
|
||||
|
||||
@@ -688,6 +688,7 @@
|
||||
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
|
||||
"ADMIN_SYNC_BACK": "⬅️ К синхронизации",
|
||||
"ADMIN_SYNC_TO_PANEL": "⬆️ Синхронизация в панель",
|
||||
"ADMIN_SYNC_CLEANUP": "🧹 Очистка",
|
||||
"ADMIN_SYNC_CONFIRM": "✅ Подтвердить",
|
||||
"ADMIN_SYNC_FULL": "🔄 Полная синхронизация",
|
||||
@@ -734,6 +735,13 @@
|
||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
|
||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Процент комиссии: {percent}% (стандартное значение)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Изменить процент",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Индивидуальный процент реферальной комиссии</b>\n\nТекущее значение: {current}%\nСтандартное значение: {default}%\n\nОтправьте новое значение от 0 до 100 или слово 'стандарт' для сброса.",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Сбросить на стандартный",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Процент обновлён: {percent}%",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Введите число от 0 до 100 или слово 'стандарт'",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
|
||||
@@ -869,6 +877,7 @@
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Отключены страны:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
|
||||
"COUNTRIES_MINIMUM_REQUIRED": "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна.",
|
||||
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Управление странами подписки</b>\n\n📋 <b>Текущие страны ({current_count}):</b>\n{current_list}\n\n💡 <b>Инструкция:</b>\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
|
||||
@@ -1090,6 +1099,8 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
|
||||
@@ -1234,8 +1245,14 @@
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
|
||||
"RULES_DECLINE": "❌ Не принимаю",
|
||||
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
|
||||
"PRIVACY_POLICY_ACCEPT": "✅ Принять",
|
||||
"PRIVACY_POLICY_DECLINE": "❌ Отклонить",
|
||||
"PRIVACY_POLICY_REQUIRED": "Для использования бота необходимо принять политику конфиденциальности.",
|
||||
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Политика конфиденциальности принята! Продолжаем регистрацию...",
|
||||
"ERROR_PRIVACY_POLICY_RETRY": "Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:",
|
||||
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
|
||||
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Политика конфиденциальности</b>\n\nМы обязуемся защищать вашу конфиденциальность и личные данные.\n\n<b>Сбор данных:</b>\n• Мы собираем только необходимую информацию для предоставления услуг\n• Данные используются исключительно для работы сервиса\n\n<b>Защита данных:</b>\n• Ваши данные защищены современными методами шифрования\n• Мы не передаем данные третьим лицам без вашего согласия\n\n<b>Ваши права:</b>\n• Вы можете запросить информацию о хранимых данных\n• Вы можете запросить удаление ваших данных\n\n<b>Используя сервис, вы соглашаетесь с политикой конфиденциальности.</b>",
|
||||
"SELECT_COUNTRIES": "Выберите страны:",
|
||||
"SELECT_DEVICES": "Количество устройств:",
|
||||
"SELECT_PERIOD": "Выберите период:",
|
||||
|
||||
@@ -687,6 +687,7 @@
|
||||
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Підтримка**\n\n",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Тікети підтримки",
|
||||
"ADMIN_SYNC_BACK": "⬅️ До синхронізації",
|
||||
"ADMIN_SYNC_TO_PANEL": "⬆️ Синхронізація в панель",
|
||||
"ADMIN_SYNC_CLEANUP": "🧹 Очищення",
|
||||
"ADMIN_SYNC_CONFIRM": "✅ Підтвердити",
|
||||
"ADMIN_SYNC_FULL": "🔄 Повна синхронізація",
|
||||
@@ -730,12 +731,19 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Користувач вже перебуває у цій промогрупі.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ До користувача",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогрупа",
|
||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Реферали",
|
||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Реферали користувача</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всього рефералів: {count}",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералів:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … і ще {count} рефералів",
|
||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Реферали",
|
||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Реферали користувача</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всього рефералів: {count}",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Відсоток комісії: {percent}% (стандартне значення)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Індивідуальний відсоток: {percent}% (стандарт: {default_percent}%)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Змінити відсоток",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Індивідуальний відсоток реферальної комісії</b>\n\nПоточне значення: {current}%\nСтандартне значення: {default}%\n\nНадішліть нове значення від 0 до 100 або слово 'стандарт' для скидання.",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Скинути на стандартний",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Відсоток оновлено: {percent}%",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Введіть число від 0 до 100 або слово 'стандарт'",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералів:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … і ще {count} рефералів",
|
||||
"ADMIN_USER_REFERRALS_EMPTY": "Рефералів поки немає.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Щоб змінити список, натисніть «✏️ Редагувати» нижче.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редагувати",
|
||||
@@ -1085,10 +1093,12 @@
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банківська картка (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "швидко та зручно",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через підтримку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через підтримку</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банківська картка</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банківська картка</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему швидких платежів YooKassa",
|
||||
|
||||
@@ -733,6 +733,13 @@
|
||||
"ADMIN_USER_REFERRALS_BUTTON":"🤝推荐",
|
||||
"ADMIN_USER_REFERRALS_TITLE":"🤝<b>用户推荐</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY":"👤{name}(ID:<code>{telegram_id}</code>)\n👥总推荐数:{count}",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT":"• 佣金比例:{percent}%(默认值)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM":"• 自定义比例:{percent}%(默认:{default_percent}%)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON":"📈 修改比例",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT":"📈 <b>自定义推荐佣金比例</b>\n\n当前值:{current}%\n默认值:{default}%\n\n发送0到100之间的数值或输入“标准”以恢复默认。",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON":"♻️ 恢复默认",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED":"✅ 比例已更新:{percent}%",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_INVALID":"❌ 请输入0到100之间的数字或“标准”",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER":"<b>推荐列表:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM":"•{name}(ID:<code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED":"•…以及其他{count}个推荐",
|
||||
@@ -1089,6 +1096,8 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME":"🛠️<b>通过支持</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION":"通过Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME":"💳<b>银行卡</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION":"通过Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME":"🪙<b>加密货币(Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION":"通过YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME":"💳<b>银行卡</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION":"通过YooKassa快速支付系统",
|
||||
|
||||
@@ -234,6 +234,19 @@ def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
return fallback.get(default_key, "")
|
||||
|
||||
|
||||
def _get_default_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
default_key = "PRIVACY_POLICY_TEXT_DEFAULT"
|
||||
locale = load_locale(language)
|
||||
if default_key in locale:
|
||||
return locale[default_key]
|
||||
fallback = load_locale(DEFAULT_LANGUAGE)
|
||||
return fallback.get(default_key, "")
|
||||
|
||||
|
||||
def get_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
return _get_default_privacy_policy(language)
|
||||
|
||||
|
||||
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
@@ -118,6 +118,7 @@ class AuthMiddleware(BaseMiddleware):
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_language.state,
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_privacy_policy_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
@@ -128,7 +129,7 @@ class AuthMiddleware(BaseMiddleware):
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (
|
||||
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
|
||||
event.data in ['rules_accept', 'rules_decline', 'privacy_policy_accept', 'privacy_policy_decline', 'referral_skip']
|
||||
or event.data.startswith('language_select:')
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.exc import MissingGreenlet
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.subscription_event import create_subscription_event
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
from app.database.models import (
|
||||
@@ -92,6 +93,51 @@ class AdminNotificationService:
|
||||
return "IDUnknown"
|
||||
return f"ID{telegram_id}"
|
||||
|
||||
async def _record_subscription_event(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
event_type: str,
|
||||
user: User,
|
||||
subscription: Subscription | None,
|
||||
transaction: Transaction | None = None,
|
||||
amount_kopeks: int | None = None,
|
||||
message: str | None = None,
|
||||
extra: Dict[str, Any] | None = None,
|
||||
occurred_at: datetime | None = None,
|
||||
) -> None:
|
||||
"""Persist subscription-related event for external dashboards."""
|
||||
|
||||
try:
|
||||
await create_subscription_event(
|
||||
db,
|
||||
user_id=user.id,
|
||||
event_type=event_type,
|
||||
subscription_id=subscription.id if subscription else None,
|
||||
transaction_id=transaction.id if transaction else None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=None,
|
||||
message=message,
|
||||
occurred_at=occurred_at,
|
||||
extra=extra or None,
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Не удалось сохранить событие подписки (%s) для пользователя %s",
|
||||
event_type,
|
||||
getattr(user, "id", "unknown"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Не удалось выполнить rollback после ошибки события подписки пользователя %s",
|
||||
getattr(user, "id", "unknown"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||
discount_lines: List[str] = []
|
||||
|
||||
@@ -194,10 +240,27 @@ class AdminNotificationService:
|
||||
*,
|
||||
charged_amount_kopeks: Optional[int] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._record_subscription_event(
|
||||
db,
|
||||
event_type="activation",
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=None,
|
||||
amount_kopeks=charged_amount_kopeks,
|
||||
message="Trial activation",
|
||||
occurred_at=datetime.utcnow(),
|
||||
extra={
|
||||
"charged_amount_kopeks": charged_amount_kopeks,
|
||||
"trial_duration_days": settings.TRIAL_DURATION_DAYS,
|
||||
"traffic_limit_gb": settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
"device_limit": subscription.device_limit,
|
||||
},
|
||||
)
|
||||
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
@@ -255,10 +318,28 @@ class AdminNotificationService:
|
||||
was_trial_conversion: bool = False,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
||||
|
||||
await self._record_subscription_event(
|
||||
db,
|
||||
event_type="purchase",
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=transaction,
|
||||
amount_kopeks=total_amount,
|
||||
message="Subscription purchase",
|
||||
occurred_at=(transaction.completed_at or transaction.created_at) if transaction else datetime.utcnow(),
|
||||
extra={
|
||||
"period_days": period_days,
|
||||
"was_trial_conversion": was_trial_conversion,
|
||||
"payment_method": self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс",
|
||||
},
|
||||
)
|
||||
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
|
||||
|
||||
if was_trial_conversion:
|
||||
@@ -275,7 +356,6 @@ class AdminNotificationService:
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
||||
transaction_id = transaction.id if transaction else "—"
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
@@ -469,11 +549,38 @@ class AdminNotificationService:
|
||||
promo_group: PromoGroup | None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
logger.info("Начинаем отправку уведомления о пополнении баланса")
|
||||
|
||||
if db:
|
||||
try:
|
||||
await self._record_subscription_event(
|
||||
db,
|
||||
event_type="balance_topup",
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=transaction,
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
message="Balance top-up",
|
||||
occurred_at=transaction.completed_at or transaction.created_at,
|
||||
extra={
|
||||
"status": topup_status,
|
||||
"balance_before": old_balance,
|
||||
"balance_after": user.balance_kopeks,
|
||||
"referrer_info": referrer_info,
|
||||
"promo_group_id": getattr(promo_group, "id", None),
|
||||
"promo_group_name": getattr(promo_group, "name", None),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Не удалось сохранить событие пополнения баланса пользователя %s",
|
||||
getattr(user, "id", "unknown"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
logger.info("Начинаем отправку уведомления о пополнении баланса")
|
||||
|
||||
try:
|
||||
logger.info("Пытаемся создать сообщение уведомления")
|
||||
message = self._build_balance_topup_message(
|
||||
@@ -567,19 +674,37 @@ class AdminNotificationService:
|
||||
new_end_date: datetime | None = None,
|
||||
balance_after: int | None = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
await self._record_subscription_event(
|
||||
db,
|
||||
event_type="renewal",
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=transaction,
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
message="Subscription renewed",
|
||||
occurred_at=transaction.completed_at or transaction.created_at,
|
||||
extra={
|
||||
"extended_days": extended_days,
|
||||
"previous_end_date": old_end_date.isoformat(),
|
||||
"new_end_date": current_end_date.isoformat(),
|
||||
"payment_method": transaction.payment_method,
|
||||
"balance_after": current_balance,
|
||||
},
|
||||
)
|
||||
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
@@ -619,7 +744,41 @@ class AdminNotificationService:
|
||||
user: User,
|
||||
promocode_data: Dict[str, Any],
|
||||
effect_description: str,
|
||||
balance_before_kopeks: int | None = None,
|
||||
balance_after_kopeks: int | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
await self._record_subscription_event(
|
||||
db,
|
||||
event_type="promocode_activation",
|
||||
user=user,
|
||||
subscription=None,
|
||||
transaction=None,
|
||||
amount_kopeks=promocode_data.get("balance_bonus_kopeks"),
|
||||
message="Promocode activation",
|
||||
occurred_at=datetime.utcnow(),
|
||||
extra={
|
||||
"code": promocode_data.get("code"),
|
||||
"type": promocode_data.get("type"),
|
||||
"subscription_days": promocode_data.get("subscription_days"),
|
||||
"balance_bonus_kopeks": promocode_data.get("balance_bonus_kopeks"),
|
||||
"description": effect_description,
|
||||
"valid_until": (
|
||||
promocode_data.get("valid_until").isoformat()
|
||||
if isinstance(promocode_data.get("valid_until"), datetime)
|
||||
else promocode_data.get("valid_until")
|
||||
),
|
||||
"balance_before_kopeks": balance_before_kopeks,
|
||||
"balance_after_kopeks": balance_after_kopeks,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Не удалось сохранить событие активации промокода пользователя %s",
|
||||
getattr(user, "id", "unknown"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
@@ -665,6 +824,13 @@ class AdminNotificationService:
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
"💼 <b>Баланс:</b>",
|
||||
(
|
||||
f"{settings.format_price(balance_before_kopeks)} → {settings.format_price(balance_after_kopeks)}"
|
||||
if balance_before_kopeks is not None and balance_after_kopeks is not None
|
||||
else "ℹ️ Баланс не изменился"
|
||||
),
|
||||
"",
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
@@ -686,6 +852,31 @@ class AdminNotificationService:
|
||||
campaign: AdvertisingCampaign,
|
||||
user: Optional[User] = None,
|
||||
) -> bool:
|
||||
if user:
|
||||
try:
|
||||
await self._record_subscription_event(
|
||||
db,
|
||||
event_type="referral_link_visit",
|
||||
user=user,
|
||||
subscription=None,
|
||||
transaction=None,
|
||||
amount_kopeks=None,
|
||||
message="Referral link visit",
|
||||
occurred_at=datetime.utcnow(),
|
||||
extra={
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_name": campaign.name,
|
||||
"start_parameter": campaign.start_parameter,
|
||||
"was_registered": bool(user),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Не удалось сохранить событие перехода по кампании для пользователя %s",
|
||||
getattr(user, "id", "unknown"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
@@ -744,6 +935,33 @@ class AdminNotificationService:
|
||||
initiator: Optional[User] = None,
|
||||
automatic: bool = False,
|
||||
) -> bool:
|
||||
try:
|
||||
await self._record_subscription_event(
|
||||
db,
|
||||
event_type="promo_group_change",
|
||||
user=user,
|
||||
subscription=None,
|
||||
transaction=None,
|
||||
message="Promo group change",
|
||||
occurred_at=datetime.utcnow(),
|
||||
extra={
|
||||
"old_group_id": getattr(old_group, "id", None),
|
||||
"old_group_name": getattr(old_group, "name", None),
|
||||
"new_group_id": new_group.id,
|
||||
"new_group_name": new_group.name,
|
||||
"reason": reason,
|
||||
"initiator_id": getattr(initiator, "id", None),
|
||||
"initiator_telegram_id": getattr(initiator, "telegram_id", None),
|
||||
"automatic": automatic,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Не удалось сохранить событие смены промогруппы пользователя %s",
|
||||
getattr(user, "id", "unknown"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import BroadcastHistory
|
||||
@@ -22,6 +23,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_MEDIA_TYPES = {"photo", "video", "document"}
|
||||
LARGE_BROADCAST_THRESHOLD = 20_000
|
||||
PROGRESS_UPDATE_STEP = 5_000
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -137,52 +140,53 @@ class BroadcastService:
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
if len(recipients) > LARGE_BROADCAST_THRESHOLD:
|
||||
logger.info(
|
||||
"Запускаем стабильный режим рассылки для %s получателей", len(recipients)
|
||||
)
|
||||
(
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled_during_run,
|
||||
) = await self._run_resilient_broadcast(
|
||||
broadcast_id,
|
||||
recipients,
|
||||
config,
|
||||
keyboard,
|
||||
cancel_event,
|
||||
)
|
||||
else:
|
||||
(
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled_during_run,
|
||||
) = await self._run_standard_broadcast(
|
||||
broadcast_id,
|
||||
recipients,
|
||||
config,
|
||||
keyboard,
|
||||
cancel_event,
|
||||
)
|
||||
|
||||
async def send_single_message(user):
|
||||
"""Отправляет одно сообщение с семафором ограничения"""
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
if cancelled_during_run:
|
||||
logger.info(
|
||||
"Рассылка %s была отменена во время выполнения, финальный статус уже установлен",
|
||||
broadcast_id,
|
||||
)
|
||||
return
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
return False
|
||||
if cancel_event.is_set():
|
||||
logger.info(
|
||||
"Запрос на отмену рассылки %s пришел после завершения отправки, фиксируем итоговый статус",
|
||||
broadcast_id,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(recipients), batch_size):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
batch = recipients[i:i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
await self._mark_finished(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled=False,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
@@ -198,6 +202,127 @@ class BroadcastService:
|
||||
return await get_custom_users(session, criteria)
|
||||
return await get_target_users(session, target)
|
||||
|
||||
async def _run_standard_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
recipients: list,
|
||||
config: BroadcastConfig,
|
||||
keyboard: Optional[InlineKeyboardMarkup],
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Базовый режим рассылки для небольших списков."""
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
async def send_single_message(user):
|
||||
"""Отправляет одно сообщение с семафором ограничения"""
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(recipients), batch_size):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
|
||||
batch = recipients[i:i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
|
||||
async def _run_resilient_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
recipients: list,
|
||||
config: BroadcastConfig,
|
||||
keyboard: Optional[InlineKeyboardMarkup],
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Режим рассылки с периодическим обновлением статуса для больших списков."""
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(15)
|
||||
|
||||
async def send_single_message(user):
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
batch_size = 100
|
||||
for i in range(0, len(recipients), batch_size):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
|
||||
batch = recipients[i:i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
processed = sent_count + failed_count
|
||||
if processed % PROGRESS_UPDATE_STEP == 0:
|
||||
await self._update_progress(broadcast_id, sent_count, failed_count)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
|
||||
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
|
||||
if selected_buttons is None:
|
||||
selected_buttons = []
|
||||
@@ -251,18 +376,14 @@ class BroadcastService:
|
||||
*,
|
||||
cancelled: bool,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count
|
||||
broadcast.status = "cancelled" if cancelled else (
|
||||
await self._safe_status_update(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
status="cancelled" if cancelled else (
|
||||
"completed" if failed_count == 0 else "partial"
|
||||
)
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
),
|
||||
)
|
||||
|
||||
async def _mark_cancelled(
|
||||
self,
|
||||
@@ -283,16 +404,70 @@ class BroadcastService:
|
||||
sent_count: int = 0,
|
||||
failed_count: int = 0,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
await self._safe_status_update(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
status="failed",
|
||||
)
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count or broadcast.failed_count
|
||||
broadcast.status = "failed"
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
async def _update_progress(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
"""Периодически обновляет прогресс рассылки, чтобы держать соединение активным."""
|
||||
|
||||
await self._safe_status_update(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
status="in_progress",
|
||||
update_completed_at=False,
|
||||
)
|
||||
|
||||
async def _safe_status_update(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
*,
|
||||
status: str,
|
||||
update_completed_at: bool = True,
|
||||
) -> None:
|
||||
attempts = 0
|
||||
|
||||
while attempts < 2:
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count
|
||||
broadcast.status = status
|
||||
|
||||
if update_completed_at:
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
return
|
||||
except InterfaceError as exc:
|
||||
attempts += 1
|
||||
logger.warning(
|
||||
"Проблемы с соединением при обновлении статуса рассылки %s: %s. Повтор %s/2",
|
||||
broadcast_id,
|
||||
exc,
|
||||
attempts,
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Не удалось обновить статус рассылки %s", broadcast_id
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
broadcast_service = BroadcastService()
|
||||
|
||||
@@ -38,9 +38,28 @@ class PaymentCommonMixin:
|
||||
texts = get_texts(user.language if user else "ru")
|
||||
|
||||
# Определяем статус подписки, чтобы показать подходящую кнопку.
|
||||
has_active_subscription = bool(
|
||||
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
|
||||
)
|
||||
has_active_subscription = False
|
||||
subscription = None
|
||||
if user:
|
||||
try:
|
||||
subscription = user.subscription
|
||||
has_active_subscription = bool(
|
||||
subscription
|
||||
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 Exception as error: # pragma: no cover - защитный код
|
||||
logger.error(
|
||||
"Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s",
|
||||
getattr(user, "id", None),
|
||||
error,
|
||||
)
|
||||
|
||||
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
|
||||
first_button = build_miniapp_or_callback_button(
|
||||
@@ -86,7 +105,7 @@ class PaymentCommonMixin:
|
||||
])
|
||||
else:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
if should_offer_checkout_resume(user, draft_exists, subscription=subscription):
|
||||
keyboard_rows.append([
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
|
||||
@@ -381,7 +381,7 @@ class CryptoBotPaymentMixin:
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
total_amount=settings.format_price(amount_kopeks)
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -402,7 +402,7 @@ class CryptoBotPaymentMixin:
|
||||
saved_cart_notification = _SavedCartNotificationPayload(
|
||||
telegram_id=user.telegram_id,
|
||||
text=(
|
||||
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
|
||||
@@ -128,6 +128,7 @@ class PlategaPaymentMixin:
|
||||
"status": status,
|
||||
"expires_at": expires_at,
|
||||
"correlation_id": correlation_id,
|
||||
"payload": payload_token,
|
||||
}
|
||||
|
||||
async def process_platega_webhook(
|
||||
@@ -335,6 +336,16 @@ class PlategaPaymentMixin:
|
||||
logger.error("Пользователь %s не найден для Platega", payment.user_id)
|
||||
return payment
|
||||
|
||||
# Убеждаемся, что промогруппы загружены в асинхронном контексте,
|
||||
# чтобы избежать попыток ленивой загрузки без greenlet
|
||||
await db.refresh(user, attribute_names=["promo_group", "user_promo_groups"])
|
||||
for user_promo_group in getattr(user, "user_promo_groups", []):
|
||||
await db.refresh(user_promo_group, attribute_names=["promo_group"])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = (
|
||||
str(payload.get("id"))
|
||||
if isinstance(payload, dict) and payload.get("id")
|
||||
@@ -392,10 +403,6 @@ class PlategaPaymentMixin:
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
try:
|
||||
|
||||
@@ -56,14 +56,7 @@ class TelegramStarsMixin:
|
||||
|
||||
# Если количество звёзд не задано, вычисляем его из курса.
|
||||
if stars_amount is None:
|
||||
rate = Decimal(str(settings.get_stars_rate()))
|
||||
if rate <= 0:
|
||||
raise ValueError("Stars rate must be positive")
|
||||
|
||||
normalized_stars = (amount_rubles / rate).to_integral_value(
|
||||
rounding=ROUND_FLOOR
|
||||
)
|
||||
stars_amount = int(normalized_stars) or 1
|
||||
stars_amount = settings.rubles_to_stars(float(amount_rubles))
|
||||
|
||||
if stars_amount <= 0:
|
||||
raise ValueError("Stars amount must be positive")
|
||||
|
||||
@@ -52,7 +52,10 @@ class PromoCodeService:
|
||||
if existing_use:
|
||||
return {"success": False, "error": "already_used_by_user"}
|
||||
|
||||
balance_before_kopeks = user.balance_kopeks
|
||||
|
||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||
balance_after_kopeks = user.balance_kopeks
|
||||
|
||||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
@@ -123,6 +126,8 @@ class PromoCodeService:
|
||||
"success": True,
|
||||
"description": result_description,
|
||||
"promocode": promocode_data,
|
||||
"balance_before_kopeks": balance_before_kopeks,
|
||||
"balance_after_kopeks": balance_after_kopeks,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.config import settings
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.referral import create_referral_earning
|
||||
from app.database.models import TransactionType, ReferralEarning
|
||||
from app.utils.user_utils import get_effective_referral_commission_percent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,8 +49,9 @@ async def process_referral_registration(
|
||||
amount_kopeks=0,
|
||||
reason="referral_registration_pending"
|
||||
)
|
||||
|
||||
|
||||
if bot:
|
||||
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||
referral_notification = (
|
||||
f"🎉 <b>Добро пожаловать!</b>\n\n"
|
||||
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
|
||||
@@ -64,8 +66,8 @@ async def process_referral_registration(
|
||||
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
|
||||
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
|
||||
f"вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или "
|
||||
f"{settings.REFERRAL_COMMISSION_PERCENT}% от суммы (что больше).\n\n"
|
||||
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
f"{commission_percent}% от суммы (что больше).\n\n"
|
||||
f"📈 С каждого последующего пополнения вы будете получать {commission_percent}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
|
||||
|
||||
@@ -94,13 +96,14 @@ async def process_referral_topup(
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||
qualifies_for_first_bonus = (
|
||||
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
|
||||
)
|
||||
commission_amount = 0
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
if commission_percent > 0:
|
||||
commission_amount = int(
|
||||
topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
|
||||
topup_amount_kopeks * commission_percent / 100
|
||||
)
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
@@ -116,7 +119,7 @@ async def process_referral_topup(
|
||||
db,
|
||||
referrer,
|
||||
commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
f"Комиссия {commission_percent}% с пополнения {user.full_name}",
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
@@ -139,7 +142,7 @@ async def process_referral_topup(
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"🎁 Ваша комиссия ({commission_percent}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
@@ -180,7 +183,7 @@ async def process_referral_topup(
|
||||
)
|
||||
await send_referral_notification(bot, user.telegram_id, bonus_notification)
|
||||
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
commission_amount = int(topup_amount_kopeks * commission_percent / 100)
|
||||
inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
|
||||
|
||||
if inviter_bonus > 0:
|
||||
@@ -204,7 +207,7 @@ async def process_referral_topup(
|
||||
f"💰 <b>Реферальная награда!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
|
||||
f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n"
|
||||
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
f"📈 Теперь с каждого его пополнения вы будете получать {commission_percent}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
@@ -212,7 +215,7 @@ async def process_referral_topup(
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
f"Комиссия {commission_percent}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
@@ -231,7 +234,7 @@ async def process_referral_topup(
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"🎁 Ваша комиссия ({commission_percent}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
@@ -261,11 +264,7 @@ async def process_referral_purchase(
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
|
||||
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!")
|
||||
commission_percent = 10
|
||||
else:
|
||||
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||
|
||||
commission_amount = int(purchase_amount_kopeks * commission_percent / 100)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import re
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -46,6 +47,26 @@ from app.utils.timezone import get_local_timezone
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_user_traffic_bytes(panel_user: Dict[str, Any]) -> int:
|
||||
"""Извлекает usedTrafficBytes из панельного пользователя (совместимо с новым и старым API)"""
|
||||
# Новый формат: userTraffic.usedTrafficBytes
|
||||
user_traffic = panel_user.get('userTraffic')
|
||||
if user_traffic and isinstance(user_traffic, dict):
|
||||
return user_traffic.get('usedTrafficBytes', 0)
|
||||
# Старый формат: usedTrafficBytes напрямую
|
||||
return panel_user.get('usedTrafficBytes', 0)
|
||||
|
||||
|
||||
def _get_lifetime_traffic_bytes(panel_user: Dict[str, Any]) -> int:
|
||||
"""Извлекает lifetimeUsedTrafficBytes из панельного пользователя (совместимо с новым и старым API)"""
|
||||
# Новый формат: userTraffic.lifetimeUsedTrafficBytes
|
||||
user_traffic = panel_user.get('userTraffic')
|
||||
if user_traffic and isinstance(user_traffic, dict):
|
||||
return user_traffic.get('lifetimeUsedTrafficBytes', 0)
|
||||
# Старый формат: lifetimeUsedTrafficBytes напрямую
|
||||
return panel_user.get('lifetimeUsedTrafficBytes', 0)
|
||||
|
||||
|
||||
_UUID_MAP_MISSING = object()
|
||||
|
||||
|
||||
@@ -256,6 +277,29 @@ class RemnaWaveService:
|
||||
)
|
||||
return self._now_utc() + timedelta(days=30)
|
||||
|
||||
def _safe_expire_at_for_panel(self, expire_at: Optional[datetime]) -> datetime:
|
||||
"""Гарантирует, что дата окончания не в прошлом для панели."""
|
||||
|
||||
now = self._now_utc()
|
||||
minimum_expire = now + timedelta(minutes=1)
|
||||
|
||||
if not expire_at:
|
||||
return minimum_expire
|
||||
|
||||
normalized_expire = expire_at
|
||||
if normalized_expire.tzinfo is not None:
|
||||
normalized_expire = normalized_expire.replace(tzinfo=None)
|
||||
|
||||
if normalized_expire < minimum_expire:
|
||||
logger.debug(
|
||||
"⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели",
|
||||
normalized_expire,
|
||||
minimum_expire,
|
||||
)
|
||||
return minimum_expire
|
||||
|
||||
return normalized_expire
|
||||
|
||||
def _safe_panel_expire_date(self, panel_user: Dict[str, Any]) -> datetime:
|
||||
"""Парсит дату окончания подписки пользователя панели для сравнения."""
|
||||
|
||||
@@ -539,6 +583,13 @@ class RemnaWaveService:
|
||||
nodes_weekly_data = list(nodes_by_name.values())
|
||||
nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True)
|
||||
|
||||
uptime_seconds = 0
|
||||
uptime_value = system_stats.get('uptime')
|
||||
try:
|
||||
uptime_seconds = int(float(uptime_value)) if uptime_value is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"Не удалось преобразовать uptime '{uptime_value}' в число, используем 0")
|
||||
|
||||
result = {
|
||||
"system": {
|
||||
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
||||
@@ -558,7 +609,7 @@ class RemnaWaveService:
|
||||
"memory_used": system_stats.get('memory', {}).get('used', 0),
|
||||
"memory_free": system_stats.get('memory', {}).get('free', 0),
|
||||
"memory_available": system_stats.get('memory', {}).get('available', 0),
|
||||
"uptime_seconds": system_stats.get('uptime', 0)
|
||||
"uptime_seconds": uptime_seconds
|
||||
},
|
||||
"bandwidth": {
|
||||
"realtime_download": total_download,
|
||||
@@ -730,7 +781,20 @@ class RemnaWaveService:
|
||||
"is_xray_running": node.is_xray_running,
|
||||
"users_online": node.users_online or 0,
|
||||
"traffic_used_bytes": node.traffic_used_bytes or 0,
|
||||
"traffic_limit_bytes": node.traffic_limit_bytes or 0
|
||||
"traffic_limit_bytes": node.traffic_limit_bytes or 0,
|
||||
"last_status_change": node.last_status_change,
|
||||
"last_status_message": node.last_status_message,
|
||||
"xray_uptime": node.xray_uptime,
|
||||
"is_traffic_tracking_active": node.is_traffic_tracking_active,
|
||||
"traffic_reset_day": node.traffic_reset_day,
|
||||
"notify_percent": node.notify_percent,
|
||||
"consumption_multiplier": node.consumption_multiplier,
|
||||
"cpu_count": node.cpu_count,
|
||||
"cpu_model": node.cpu_model,
|
||||
"total_ram": node.total_ram,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"provider_uuid": node.provider_uuid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -788,15 +852,19 @@ class RemnaWaveService:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
squads = await api.get_internal_squads()
|
||||
|
||||
|
||||
result = []
|
||||
for squad in squads:
|
||||
inbounds = [
|
||||
asdict(inbound) if is_dataclass(inbound) else inbound
|
||||
for inbound in squad.inbounds or []
|
||||
]
|
||||
result.append({
|
||||
'uuid': squad.uuid,
|
||||
'name': squad.name,
|
||||
'members_count': squad.members_count,
|
||||
'inbounds_count': squad.inbounds_count,
|
||||
'inbounds': squad.inbounds
|
||||
'inbounds': inbounds,
|
||||
})
|
||||
|
||||
logger.info(f"✅ Получено {len(result)} сквадов из Remnawave")
|
||||
@@ -1433,10 +1501,10 @@ class RemnaWaveService:
|
||||
|
||||
traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0)
|
||||
traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0
|
||||
|
||||
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
|
||||
|
||||
used_traffic_bytes = _get_user_traffic_bytes(panel_user)
|
||||
traffic_used_gb = used_traffic_bytes / (1024**3)
|
||||
|
||||
|
||||
active_squads = panel_user.get('activeInternalSquads', [])
|
||||
squad_uuids = []
|
||||
if isinstance(active_squads, list):
|
||||
@@ -1538,10 +1606,10 @@ class RemnaWaveService:
|
||||
if subscription.status != new_status:
|
||||
subscription.status = new_status
|
||||
logger.debug(f"Обновлен статус подписки: {new_status}")
|
||||
|
||||
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
|
||||
|
||||
used_traffic_bytes = _get_user_traffic_bytes(panel_user)
|
||||
traffic_used_gb = used_traffic_bytes / (1024**3)
|
||||
|
||||
|
||||
if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01:
|
||||
subscription.traffic_used_gb = traffic_used_gb
|
||||
logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB")
|
||||
@@ -1608,39 +1676,28 @@ class RemnaWaveService:
|
||||
async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]:
|
||||
try:
|
||||
stats = {"created": 0, "updated": 0, "errors": 0}
|
||||
|
||||
users = await get_users_list(db, offset=0, limit=10000)
|
||||
|
||||
|
||||
batch_size = 100
|
||||
offset = 0
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
for user in users:
|
||||
if not user.subscription:
|
||||
continue
|
||||
while True:
|
||||
users = await get_users_list(db, offset=offset, limit=batch_size)
|
||||
|
||||
try:
|
||||
subscription = user.subscription
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
if not users:
|
||||
break
|
||||
|
||||
if user.remnawave_uuid:
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
for user in users:
|
||||
if not user.subscription:
|
||||
continue
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
try:
|
||||
subscription = user.subscription
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
expire_at = self._safe_expire_at_for_panel(subscription.end_date)
|
||||
status = UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED
|
||||
|
||||
await api.update_user(**update_kwargs)
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
@@ -1649,8 +1706,8 @@ class RemnaWaveService:
|
||||
|
||||
create_kwargs = dict(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
expire_at=expire_at,
|
||||
status=status,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
@@ -1665,20 +1722,66 @@ class RemnaWaveService:
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
new_user = await api.create_user(**create_kwargs)
|
||||
|
||||
await update_user(db, user, remnawave_uuid=new_user.uuid)
|
||||
subscription.remnawave_short_uuid = new_user.short_uuid
|
||||
# Убираем немедленный коммит для пакетной обработки
|
||||
# await db.commit()
|
||||
|
||||
stats["created"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
|
||||
stats["errors"] += 1
|
||||
|
||||
logger.info(f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}")
|
||||
if user.remnawave_uuid:
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=status,
|
||||
expire_at=expire_at,
|
||||
traffic_limit_bytes=create_kwargs['traffic_limit_bytes'],
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
description=create_kwargs['description'],
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
try:
|
||||
await api.update_user(**update_kwargs)
|
||||
stats["updated"] += 1
|
||||
except RemnaWaveAPIError as api_error:
|
||||
if api_error.status_code == 404:
|
||||
logger.warning(
|
||||
"⚠️ Не найден пользователь %s в панели, создаем заново",
|
||||
user.remnawave_uuid,
|
||||
)
|
||||
|
||||
new_user = await api.create_user(**create_kwargs)
|
||||
user.remnawave_uuid = new_user.uuid
|
||||
subscription.remnawave_short_uuid = new_user.short_uuid
|
||||
stats["created"] += 1
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
new_user = await api.create_user(**create_kwargs)
|
||||
|
||||
user.remnawave_uuid = new_user.uuid
|
||||
subscription.remnawave_short_uuid = new_user.short_uuid
|
||||
|
||||
stats["created"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
|
||||
stats["errors"] += 1
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception as commit_error:
|
||||
logger.error(
|
||||
"Ошибка фиксации транзакции при синхронизации в панель: %s",
|
||||
commit_error,
|
||||
)
|
||||
await db.rollback()
|
||||
stats["errors"] += len(users)
|
||||
|
||||
if len(users) < batch_size:
|
||||
break
|
||||
|
||||
offset += batch_size
|
||||
|
||||
logger.info(
|
||||
f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}"
|
||||
)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
@@ -1762,12 +1865,16 @@ class RemnaWaveService:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.get_internal_squad_by_uuid(squad_uuid)
|
||||
if squad:
|
||||
inbounds = [
|
||||
asdict(inbound) if is_dataclass(inbound) else inbound
|
||||
for inbound in squad.inbounds or []
|
||||
]
|
||||
return {
|
||||
'uuid': squad.uuid,
|
||||
'name': squad.name,
|
||||
'members_count': squad.members_count,
|
||||
'inbounds_count': squad.inbounds_count,
|
||||
'inbounds': squad.inbounds
|
||||
'inbounds': inbounds
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
|
||||
@@ -124,10 +124,13 @@ def _safe_int(value: Optional[object], default: int = 0) -> int:
|
||||
|
||||
|
||||
async def _prepare_auto_extend_context(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
) -> Optional[AutoExtendContext]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription is None:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s нет активной подписки для продления",
|
||||
@@ -233,7 +236,7 @@ async def _auto_extend_subscription(
|
||||
bot: Optional[Bot] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
prepared = await _prepare_auto_extend_context(user, cart_data)
|
||||
prepared = await _prepare_auto_extend_context(db, user, cart_data)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s",
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.database.models import User
|
||||
from sqlalchemy.exc import MissingGreenlet
|
||||
|
||||
from app.database.models import Subscription, User
|
||||
from app.utils.cache import UserCache
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CHECKOUT_SESSION_KEY = "subscription_checkout"
|
||||
_CHECKOUT_TTL_SECONDS = 3600
|
||||
|
||||
@@ -33,7 +39,12 @@ async def has_subscription_checkout_draft(user_id: int) -> bool:
|
||||
return draft is not None
|
||||
|
||||
|
||||
def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
|
||||
def should_offer_checkout_resume(
|
||||
user: User,
|
||||
has_draft: bool,
|
||||
*,
|
||||
subscription: Subscription | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Determine whether checkout resume button should be available for the user.
|
||||
|
||||
@@ -44,7 +55,16 @@ def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
|
||||
if not has_draft:
|
||||
return False
|
||||
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if subscription is None:
|
||||
try:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
except MissingGreenlet as error:
|
||||
logger.warning(
|
||||
"Не удалось лениво загрузить подписку пользователя %s при проверке возврата к checkout: %s",
|
||||
getattr(user, "id", None),
|
||||
error,
|
||||
)
|
||||
subscription = None
|
||||
|
||||
if subscription is None:
|
||||
return True
|
||||
|
||||
@@ -329,7 +329,9 @@ class MiniAppSubscriptionPurchaseService:
|
||||
"""Builds configuration and pricing for subscription purchases in the mini app."""
|
||||
|
||||
async def build_options(self, db: AsyncSession, user: User) -> PurchaseOptionsContext:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0)
|
||||
currency = (getattr(user, "balance_currency", None) or "RUB").upper()
|
||||
texts = get_texts(getattr(user, "language", None))
|
||||
|
||||
@@ -132,6 +132,13 @@ class SubscriptionService:
|
||||
|
||||
self._last_config_signature = config_signature
|
||||
|
||||
@staticmethod
|
||||
def _resolve_user_tag(subscription: Subscription) -> Optional[str]:
|
||||
if getattr(subscription, "is_trial", False):
|
||||
return settings.get_trial_user_tag()
|
||||
|
||||
return settings.get_paid_subscription_user_tag()
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self._config_error is None
|
||||
@@ -173,7 +180,9 @@ class SubscriptionService:
|
||||
if not validation_success:
|
||||
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
|
||||
return None
|
||||
|
||||
|
||||
user_tag = self._resolve_user_tag(subscription)
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
@@ -201,6 +210,9 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if user_tag is not None:
|
||||
update_kwargs['tag'] = user_tag
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
@@ -236,6 +248,9 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if user_tag is not None:
|
||||
create_kwargs['tag'] = user_tag
|
||||
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
@@ -288,15 +303,17 @@ class SubscriptionService:
|
||||
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date > current_time)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
is_actually_active = False
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
|
||||
user_tag = self._resolve_user_tag(subscription)
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
@@ -314,6 +331,9 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if user_tag is not None:
|
||||
update_kwargs['tag'] = user_tag
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
@@ -521,36 +541,36 @@ class SubscriptionService:
|
||||
|
||||
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.debug("Расчет стоимости новой подписки:")
|
||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
logger.debug(base_log)
|
||||
if discounted_traffic_price > 0:
|
||||
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if total_servers_price > 0:
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if discounted_devices_price > 0:
|
||||
message = f" Устройства ({devices}): {devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
logger.debug(message)
|
||||
logger.debug(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
@@ -634,36 +654,36 @@ class SubscriptionService:
|
||||
+ discounted_traffic_price
|
||||
)
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.debug(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
logger.debug(base_log)
|
||||
if servers_price > 0:
|
||||
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
|
||||
if servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if devices_price > 0:
|
||||
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if traffic_price > 0:
|
||||
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
logger.debug(message)
|
||||
logger.debug(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price
|
||||
|
||||
@@ -855,14 +875,14 @@ class SubscriptionService:
|
||||
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.debug(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
logger.debug(base_log)
|
||||
if total_traffic_price > 0:
|
||||
message = (
|
||||
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
@@ -871,14 +891,14 @@ class SubscriptionService:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if total_servers_price > 0:
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if total_devices_price > 0:
|
||||
message = (
|
||||
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
@@ -887,8 +907,8 @@ class SubscriptionService:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
logger.debug(message)
|
||||
logger.debug(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
@@ -972,14 +992,14 @@ class SubscriptionService:
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.debug(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
logger.debug(base_log)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
||||
@@ -988,7 +1008,7 @@ class SubscriptionService:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if total_devices_price > 0:
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
@@ -997,7 +1017,7 @@ class SubscriptionService:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if total_traffic_price > 0:
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
@@ -1006,8 +1026,8 @@ class SubscriptionService:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
logger.debug(message)
|
||||
logger.debug(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ class BotConfigurationService:
|
||||
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PAID_SUBSCRIPTION_USER_TAG": "SUBSCRIPTION_PRICES",
|
||||
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
|
||||
@@ -227,6 +228,7 @@ class BotConfigurationService:
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
"TRIAL_WARNING_HOURS": "TRIAL",
|
||||
"TRIAL_USER_TAG": "TRIAL",
|
||||
"SUPPORT_USERNAME": "SUPPORT",
|
||||
"SUPPORT_MENU_ENABLED": "SUPPORT",
|
||||
"SUPPORT_SYSTEM_MODE": "SUPPORT",
|
||||
@@ -254,6 +256,7 @@ class BotConfigurationService:
|
||||
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": "SIMPLE_SUBSCRIPTION",
|
||||
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
|
||||
"SUPPORT_TOPUP_ENABLED": "PAYMENT",
|
||||
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
|
||||
@@ -504,6 +507,22 @@ class BotConfigurationService:
|
||||
"warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.",
|
||||
"dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED",
|
||||
},
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": {
|
||||
"description": (
|
||||
"Включает применение базовых скидок на периоды подписок в групповых промо."
|
||||
),
|
||||
"format": "Булево значение.",
|
||||
"example": "true",
|
||||
"warning": "Скидки применяются только если указаны корректные пары периодов и процентов.",
|
||||
},
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": {
|
||||
"description": (
|
||||
"Список скидок для групп: каждая пара задаёт дни периода и процент скидки."
|
||||
),
|
||||
"format": "Через запятую пары вида <дней>:<скидка>.",
|
||||
"example": "30:10,60:20,90:30,180:50,360:65",
|
||||
"warning": "Некорректные записи будут проигнорированы. Процент ограничен 0-100.",
|
||||
},
|
||||
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": {
|
||||
"description": (
|
||||
"При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения."
|
||||
@@ -626,6 +645,24 @@ class BotConfigurationService:
|
||||
"warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.",
|
||||
"dependencies": "Результат вызова getMe() в Telegram Bot API",
|
||||
},
|
||||
"TRIAL_USER_TAG": {
|
||||
"description": (
|
||||
"Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave."
|
||||
),
|
||||
"format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.",
|
||||
"example": "TRIAL_USER",
|
||||
"warning": "Неверный формат будет проигнорирован при создании пользователя.",
|
||||
"dependencies": "Активация триала и включенная интеграция с RemnaWave",
|
||||
},
|
||||
"PAID_SUBSCRIPTION_USER_TAG": {
|
||||
"description": (
|
||||
"Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave."
|
||||
),
|
||||
"format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.",
|
||||
"example": "PAID_USER",
|
||||
"warning": "Если тег не задан или невалиден, существующий тег не будет изменён.",
|
||||
"dependencies": "Оплата подписки и интеграция с RemnaWave",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -3,6 +3,7 @@ from aiogram.fsm.state import State, StatesGroup
|
||||
class RegistrationStates(StatesGroup):
|
||||
waiting_for_language = State()
|
||||
waiting_for_rules_accept = State()
|
||||
waiting_for_privacy_policy_accept = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
class SubscriptionStates(StatesGroup):
|
||||
@@ -95,6 +96,7 @@ class AdminStates(StatesGroup):
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
editing_user_referrals = State()
|
||||
editing_user_referral_percent = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
editing_privacy_policy = State()
|
||||
|
||||
@@ -10,12 +10,15 @@ def is_registration_process(event: TelegramObject, current_state: Optional[str])
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_language.state,
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_privacy_policy_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
registration_callbacks = [
|
||||
"rules_accept",
|
||||
"rules_decline",
|
||||
"privacy_policy_accept",
|
||||
"privacy_policy_decline",
|
||||
"referral_skip"
|
||||
]
|
||||
|
||||
|
||||
+22
-11
@@ -100,14 +100,14 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
"callback": "topup_platega",
|
||||
})
|
||||
|
||||
# Поддержка всегда доступна
|
||||
methods.append({
|
||||
"id": "support",
|
||||
"name": "Через поддержку",
|
||||
"icon": "🛠️",
|
||||
"description": "другие способы",
|
||||
"callback": "topup_support"
|
||||
})
|
||||
if settings.is_support_topup_enabled():
|
||||
methods.append({
|
||||
"id": "support",
|
||||
"name": "Через поддержку",
|
||||
"icon": "🛠️",
|
||||
"description": "другие способы",
|
||||
"callback": "topup_support"
|
||||
})
|
||||
|
||||
return methods
|
||||
|
||||
@@ -118,7 +118,18 @@ def get_payment_methods_text(language: str) -> str:
|
||||
texts = get_texts(language)
|
||||
methods = get_available_payment_methods()
|
||||
|
||||
if len(methods) <= 1: # Только поддержка
|
||||
if not methods:
|
||||
return texts.t(
|
||||
"PAYMENT_METHODS_NONE_AVAILABLE",
|
||||
"""💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
⚠️ В данный момент способы оплаты временно недоступны.
|
||||
Попробуйте позже.
|
||||
|
||||
Выберите способ пополнения:""",
|
||||
)
|
||||
|
||||
if len(methods) == 1 and methods[0]["id"] == "support":
|
||||
return texts.t(
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT",
|
||||
"""💳 <b>Способы пополнения баланса</b>
|
||||
@@ -186,7 +197,7 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
elif method_id == "platega":
|
||||
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
|
||||
elif method_id == "support":
|
||||
return True # Поддержка всегда доступна
|
||||
return settings.is_support_topup_enabled()
|
||||
else:
|
||||
return False
|
||||
|
||||
@@ -204,7 +215,7 @@ def get_payment_method_status() -> Dict[str, bool]:
|
||||
"cryptobot": settings.is_cryptobot_enabled(),
|
||||
"heleket": settings.is_heleket_enabled(),
|
||||
"platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
|
||||
"support": True
|
||||
"support": settings.is_support_topup_enabled()
|
||||
}
|
||||
|
||||
def get_enabled_payment_methods_count() -> int:
|
||||
|
||||
@@ -72,11 +72,8 @@ def calculate_user_price(
|
||||
# Get user's promo group discount for this category
|
||||
discount_percent = user.get_promo_discount(category, period_days)
|
||||
else:
|
||||
# For None user, use base settings discount (only for period category)
|
||||
if category == "period":
|
||||
discount_percent = settings.get_base_promo_group_period_discount(period_days)
|
||||
else:
|
||||
discount_percent = 0
|
||||
# For None user, use base settings discount
|
||||
discount_percent = settings.get_base_promo_group_period_discount(period_days)
|
||||
|
||||
logger.debug(
|
||||
f"calculate_user_price: user={user.telegram_id if user else 'None'}, "
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy import select, func, and_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, ReferralEarning, Transaction, TransactionType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,6 +60,25 @@ async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> s
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
def get_effective_referral_commission_percent(user: User) -> int:
|
||||
"""Возвращает индивидуальный процент комиссии пользователя или дефолтное значение."""
|
||||
|
||||
percent = getattr(user, "referral_commission_percent", None)
|
||||
|
||||
if percent is None:
|
||||
percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
if percent < 0 or percent > 100:
|
||||
logger.error(
|
||||
"❌ Некорректный процент комиссии для пользователя %s: %s",
|
||||
getattr(user, "telegram_id", None),
|
||||
percent,
|
||||
)
|
||||
return max(0, min(100, settings.REFERRAL_COMMISSION_PERCENT))
|
||||
|
||||
return percent
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool:
|
||||
try:
|
||||
if user.has_had_paid_subscription:
|
||||
|
||||
+45
-17
@@ -121,25 +121,53 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
|
||||
|
||||
|
||||
def sanitize_html(text: str) -> str:
|
||||
"""
|
||||
Безопасно санитизирует HTML-текст, заменяя HTML-сущности на соответствующие теги,
|
||||
при этом предотвращая XSS-уязвимости за счет безопасной обработки атрибутов.
|
||||
|
||||
Args:
|
||||
text (str): Текст с HTML-сущностями (например, <b> жирный </b>)
|
||||
|
||||
Returns:
|
||||
str: Санитизированный HTML-текст (например, <b> жирный </b>)
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
text = html.escape(text)
|
||||
|
||||
for tag in ALLOWED_HTML_TAGS:
|
||||
text = re.sub(
|
||||
f'<{tag}(>|\\s[^&]*>)',
|
||||
lambda m: m.group(0).replace('<', '<').replace('>', '>'),
|
||||
text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
text = re.sub(
|
||||
f'</{tag}>',
|
||||
f'</{tag}>',
|
||||
text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
# Для безопасности нужно обработать разрешенные теги, заменяя их сущности на теги
|
||||
# Но при этом безопасно обрабатывая атрибуты, чтобы избежать XSS
|
||||
|
||||
allowed_tags = ALLOWED_HTML_TAGS.union(SELF_CLOSING_TAGS)
|
||||
|
||||
# Обработка всех разрешенных тегов
|
||||
for tag in allowed_tags:
|
||||
# Паттерн: захватываем <tag>, </tag>, или <tag атрибуты>
|
||||
# Используем более сложный паттерн, чтобы захватить атрибуты до закрывающего >
|
||||
# (?s) - позволяет . захватывать новую строку
|
||||
# [^>]*? - ленивый захват до >
|
||||
pattern = rf'(<)(/?{tag}\b)([^>]*?)(>)'
|
||||
|
||||
def replace_tag(match):
|
||||
opening = match.group(1) # <
|
||||
full_tag_content = match.group(2) # /?tagname
|
||||
attrs_part = match.group(3) # атрибуты (без >)
|
||||
closing = match.group(4) # >
|
||||
|
||||
# Убираем начальный пробел, если есть
|
||||
if attrs_part.startswith(' '):
|
||||
attrs_part = attrs_part[1:]
|
||||
|
||||
# Формируем результат
|
||||
if attrs_part:
|
||||
# Безопасно обрабатываем атрибуты, заменяя только безопасные сущности
|
||||
# Не разворачиваем < и > внутри атрибутов, чтобы избежать XSS
|
||||
processed_attrs = attrs_part.replace('"', '"').replace(''', "'")
|
||||
return f'<{full_tag_content} {processed_attrs}>'
|
||||
else:
|
||||
return f'<{full_tag_content}>'
|
||||
|
||||
text = re.sub(pattern, replace_tag, text, flags=re.IGNORECASE)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
|
||||
+52
-2
@@ -4,6 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.webapi.docs import add_redoc_endpoint
|
||||
|
||||
from .middleware import RequestLoggingMiddleware
|
||||
from .routes import (
|
||||
@@ -13,14 +14,19 @@ from .routes import (
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
media,
|
||||
miniapp,
|
||||
partners,
|
||||
polls,
|
||||
promocodes,
|
||||
promo_groups,
|
||||
promo_offers,
|
||||
user_messages,
|
||||
welcome_texts,
|
||||
pages,
|
||||
remnawave,
|
||||
servers,
|
||||
subscription_events,
|
||||
stats,
|
||||
subscriptions,
|
||||
tickets,
|
||||
@@ -46,7 +52,11 @@ OPENAPI_TAGS = [
|
||||
},
|
||||
{
|
||||
"name": "main-menu",
|
||||
"description": "Управление кнопками главного меню Telegram-бота.",
|
||||
"description": "Управление кнопками и сообщениями главного меню Telegram-бота.",
|
||||
},
|
||||
{
|
||||
"name": "welcome-texts",
|
||||
"description": "Создание, редактирование и управление приветственными текстами.",
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
@@ -96,10 +106,18 @@ OPENAPI_TAGS = [
|
||||
"данных между ботом и панелью."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "media",
|
||||
"description": "Загрузка файлов в Telegram и получение ссылок на медиа.",
|
||||
},
|
||||
{
|
||||
"name": "miniapp",
|
||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||
},
|
||||
{
|
||||
"name": "partners",
|
||||
"description": "Просмотр участников реферальной программы, их доходов и рефералов.",
|
||||
},
|
||||
{
|
||||
"name": "polls",
|
||||
"description": "Создание опросов, удаление, статистика и ответы пользователей.",
|
||||
@@ -108,6 +126,14 @@ OPENAPI_TAGS = [
|
||||
"name": "pages",
|
||||
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
|
||||
},
|
||||
{
|
||||
"name": "notifications",
|
||||
"description": (
|
||||
"Получение и просмотр уведомлений о покупках, активациях и продлениях подписок, "
|
||||
"пополнениях баланса, активациях промокодов, переходах по реферальным ссылкам и "
|
||||
"сменах промогрупп пользователей для административной панели."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -119,11 +145,18 @@ def create_web_api_app() -> FastAPI:
|
||||
title=settings.WEB_API_TITLE,
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
redoc_url=None,
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
swagger_ui_parameters={"persistAuthorization": True},
|
||||
)
|
||||
|
||||
add_redoc_endpoint(
|
||||
app,
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
title=settings.WEB_API_TITLE,
|
||||
)
|
||||
|
||||
allowed_origins = settings.get_web_api_allowed_origins()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -151,6 +184,16 @@ def create_web_api_app() -> FastAPI:
|
||||
prefix="/main-menu/buttons",
|
||||
tags=["main-menu"],
|
||||
)
|
||||
app.include_router(
|
||||
user_messages.router,
|
||||
prefix="/main-menu/messages",
|
||||
tags=["main-menu"],
|
||||
)
|
||||
app.include_router(
|
||||
welcome_texts.router,
|
||||
prefix="/welcome-texts",
|
||||
tags=["welcome-texts"],
|
||||
)
|
||||
app.include_router(pages.router, prefix="/pages", tags=["pages"])
|
||||
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
|
||||
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
|
||||
@@ -158,8 +201,15 @@ def create_web_api_app() -> FastAPI:
|
||||
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
|
||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
||||
app.include_router(media.router, tags=["media"])
|
||||
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
|
||||
app.include_router(partners.router, prefix="/partners", tags=["partners"])
|
||||
app.include_router(polls.router, prefix="/polls", tags=["polls"])
|
||||
app.include_router(logs.router, prefix="/logs", tags=["logs"])
|
||||
app.include_router(
|
||||
subscription_events.router,
|
||||
prefix="/notifications/subscriptions",
|
||||
tags=["notifications"],
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.docs import get_redoc_html
|
||||
|
||||
|
||||
def add_redoc_endpoint(
|
||||
app: FastAPI,
|
||||
*,
|
||||
redoc_url: str | None,
|
||||
openapi_url: str | None,
|
||||
title: str | None,
|
||||
) -> None:
|
||||
"""Attach a ReDoc endpoint if docs are enabled.
|
||||
|
||||
The default FastAPI ReDoc handler sometimes renders a blank page when the
|
||||
CDN bundle fails to load. By explicitly registering the handler and
|
||||
pinning the bundle version, we ensure the endpoint always returns a fully
|
||||
rendered page.
|
||||
"""
|
||||
|
||||
if not redoc_url or not openapi_url:
|
||||
return
|
||||
|
||||
for route in app.router.routes:
|
||||
if getattr(route, "path", None) == redoc_url:
|
||||
return
|
||||
|
||||
@app.get(redoc_url, include_in_schema=False)
|
||||
async def redoc_html(): # pragma: no cover - template rendering
|
||||
return get_redoc_html(
|
||||
openapi_url=openapi_url,
|
||||
title=f"{title or app.title} - ReDoc",
|
||||
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js",
|
||||
)
|
||||
@@ -2,13 +2,18 @@ from . import (
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
media,
|
||||
miniapp,
|
||||
partners,
|
||||
polls,
|
||||
promo_offers,
|
||||
user_messages,
|
||||
welcome_texts,
|
||||
pages,
|
||||
promo_groups,
|
||||
servers,
|
||||
remnawave,
|
||||
subscription_events,
|
||||
stats,
|
||||
subscriptions,
|
||||
tickets,
|
||||
@@ -22,13 +27,18 @@ __all__ = [
|
||||
"config",
|
||||
"health",
|
||||
"main_menu_buttons",
|
||||
"media",
|
||||
"miniapp",
|
||||
"partners",
|
||||
"polls",
|
||||
"promo_offers",
|
||||
"user_messages",
|
||||
"welcome_texts",
|
||||
"pages",
|
||||
"promo_groups",
|
||||
"servers",
|
||||
"remnawave",
|
||||
"subscription_events",
|
||||
"stats",
|
||||
"subscriptions",
|
||||
"tickets",
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
Security,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import require_api_token
|
||||
from ..schemas.media import MediaUploadResponse
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_MEDIA_TYPES = {"photo", "video", "document"}
|
||||
|
||||
|
||||
def _resolve_target_chat_id() -> int:
|
||||
"""Выбирает чат для загрузки файлов (канал уведомлений или первый админ)."""
|
||||
|
||||
chat_id = settings.get_admin_notifications_chat_id()
|
||||
if chat_id is not None:
|
||||
return chat_id
|
||||
|
||||
admin_ids = settings.get_admin_ids()
|
||||
if admin_ids:
|
||||
return admin_ids[0]
|
||||
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
"Не настроен чат для загрузки файлов (ADMIN_NOTIFICATIONS_CHAT_ID или ADMIN_IDS)",
|
||||
)
|
||||
|
||||
|
||||
def _build_media_url(request: Request, file_id: str) -> str:
|
||||
return str(request.url_for("download_media", file_id=file_id))
|
||||
|
||||
|
||||
@router.post("/upload", response_model=MediaUploadResponse, tags=["media"], status_code=status.HTTP_201_CREATED)
|
||||
async def upload_media(
|
||||
request: Request,
|
||||
_: Any = Security(require_api_token),
|
||||
file: UploadFile = File(...),
|
||||
media_type: str = Form("document", description="Тип файла: photo, video или document"),
|
||||
caption: str | None = Form(None, description="Необязательная подпись к файлу"),
|
||||
) -> MediaUploadResponse:
|
||||
media_type_normalized = (media_type or "").strip().lower()
|
||||
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported media type")
|
||||
|
||||
file_bytes = await file.read()
|
||||
if not file_bytes:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "File is empty")
|
||||
|
||||
target_chat_id = _resolve_target_chat_id()
|
||||
upload = BufferedInputFile(file_bytes, filename=file.filename or "upload")
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
try:
|
||||
if media_type_normalized == "photo":
|
||||
message = await bot.send_photo(
|
||||
chat_id=target_chat_id,
|
||||
photo=upload,
|
||||
caption=caption,
|
||||
)
|
||||
media = message.photo[-1]
|
||||
elif media_type_normalized == "video":
|
||||
message = await bot.send_video(
|
||||
chat_id=target_chat_id,
|
||||
video=upload,
|
||||
caption=caption,
|
||||
)
|
||||
media = message.video
|
||||
else:
|
||||
message = await bot.send_document(
|
||||
chat_id=target_chat_id,
|
||||
document=upload,
|
||||
caption=caption,
|
||||
)
|
||||
media = message.document
|
||||
|
||||
media_url = _build_media_url(request, media.file_id)
|
||||
return MediaUploadResponse(
|
||||
media_type=media_type_normalized,
|
||||
file_id=media.file_id,
|
||||
file_unique_id=getattr(media, "file_unique_id", None),
|
||||
media_url=media_url,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error("Failed to upload media: %s", error)
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to upload media") from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
@router.get("/media/{file_id}", name="download_media", tags=["media"])
|
||||
async def download_media(
|
||||
file_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> Response:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
try:
|
||||
file = await bot.get_file(file_id)
|
||||
if not file.file_path:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media file not found")
|
||||
|
||||
buffer = await bot.download_file(file.file_path)
|
||||
|
||||
if hasattr(buffer, "seek"):
|
||||
buffer.seek(0)
|
||||
|
||||
content = buffer.read() if hasattr(buffer, "read") else bytes(buffer)
|
||||
filename = file.file_path.split("/")[-1]
|
||||
|
||||
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f"inline; filename={filename}",
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error: # pragma: no cover - неожиданные ошибки загрузки файла
|
||||
logger.error("Failed to download media %s: %s", file_id, error)
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to download media") from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import math
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR, ROUND_UP
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Union
|
||||
|
||||
@@ -64,6 +66,7 @@ from app.services.remnawave_service import (
|
||||
from app.services.payment_service import PaymentService, get_wata_payment_by_link_id
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.subscription_renewal_service import (
|
||||
SubscriptionRenewalChargeError,
|
||||
@@ -96,6 +99,7 @@ from app.utils.telegram_webapp import (
|
||||
parse_webapp_init_data,
|
||||
)
|
||||
from app.utils.user_utils import (
|
||||
get_effective_referral_commission_percent,
|
||||
get_detailed_referral_list,
|
||||
get_user_referral_summary,
|
||||
)
|
||||
@@ -114,6 +118,7 @@ from ..schemas.miniapp import (
|
||||
MiniAppDevice,
|
||||
MiniAppDeviceRemovalRequest,
|
||||
MiniAppDeviceRemovalResponse,
|
||||
MiniAppMaintenanceStatusResponse,
|
||||
MiniAppFaq,
|
||||
MiniAppFaqItem,
|
||||
MiniAppLegalDocuments,
|
||||
@@ -124,6 +129,7 @@ from ..schemas.miniapp import (
|
||||
MiniAppPaymentMethod,
|
||||
MiniAppPaymentMethodsRequest,
|
||||
MiniAppPaymentMethodsResponse,
|
||||
MiniAppPaymentOption,
|
||||
MiniAppPaymentStatusQuery,
|
||||
MiniAppPaymentStatusRequest,
|
||||
MiniAppPaymentStatusResponse,
|
||||
@@ -192,6 +198,57 @@ _CRYPTOBOT_MIN_USD = 1.0
|
||||
_CRYPTOBOT_MAX_USD = 1000.0
|
||||
_CRYPTOBOT_FALLBACK_RATE = 95.0
|
||||
|
||||
|
||||
@router.get("/app-config.json")
|
||||
async def get_app_config() -> Dict[str, Any]:
|
||||
data = _load_app_config_data()
|
||||
if data is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="App config not found")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _get_app_config_candidate_files() -> List[Path]:
|
||||
seen: set[Path] = set()
|
||||
candidates: List[Path] = []
|
||||
|
||||
def _add_candidate(path: Path) -> None:
|
||||
resolved = path.resolve()
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
candidates.append(resolved)
|
||||
|
||||
cwd = Path.cwd()
|
||||
_add_candidate(cwd / "miniapp" / "app-config.json")
|
||||
_add_candidate(cwd / "app-config.json")
|
||||
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
_add_candidate(parent / "miniapp" / "app-config.json")
|
||||
_add_candidate(parent / "app-config.json")
|
||||
|
||||
_add_candidate(Path("/var/www/remnawave-miniapp/app-config.json"))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _load_app_config_data() -> Optional[Dict[str, Any]]:
|
||||
for path in _get_app_config_candidate_files():
|
||||
if not path.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
logger.warning("Failed to load app-config from %s: %s", path, error)
|
||||
continue
|
||||
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
|
||||
return None
|
||||
|
||||
_DECIMAL_ONE_HUNDRED = Decimal(100)
|
||||
_DECIMAL_CENT = Decimal("0.01")
|
||||
|
||||
@@ -624,6 +681,23 @@ def _build_mulenpay_iframe_config() -> Optional[MiniAppPaymentIframeConfig]:
|
||||
return None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/maintenance/status",
|
||||
response_model=MiniAppMaintenanceStatusResponse,
|
||||
)
|
||||
async def get_maintenance_status(
|
||||
payload: MiniAppSubscriptionRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppMaintenanceStatusResponse:
|
||||
_, _ = await _resolve_user_from_init_data(db, payload.init_data)
|
||||
status_info = maintenance_service.get_status_info()
|
||||
return MiniAppMaintenanceStatusResponse(
|
||||
is_active=bool(status_info.get("is_active")),
|
||||
message=maintenance_service.get_maintenance_message(),
|
||||
reason=status_info.get("reason"),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/payments/methods",
|
||||
response_model=MiniAppPaymentMethodsResponse,
|
||||
@@ -707,6 +781,24 @@ async def get_payment_methods(
|
||||
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||||
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||
options=[
|
||||
MiniAppPaymentOption(
|
||||
id="sbp",
|
||||
icon="🏦",
|
||||
title_key="topup.method.pal24.option.sbp.title",
|
||||
description_key="topup.method.pal24.option.sbp.description",
|
||||
title="Faster Payments (SBP)",
|
||||
description="Instant SBP transfer with no fees.",
|
||||
),
|
||||
MiniAppPaymentOption(
|
||||
id="card",
|
||||
icon="💳",
|
||||
title_key="topup.method.pal24.option.card.title",
|
||||
description_key="topup.method.pal24.option.card.description",
|
||||
title="Bank card",
|
||||
description="Pay with a bank card via PayPalych.",
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -723,6 +815,37 @@ async def get_payment_methods(
|
||||
)
|
||||
)
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
platega_methods = settings.get_platega_active_methods()
|
||||
definitions = settings.get_platega_method_definitions()
|
||||
options: List[MiniAppPaymentOption] = []
|
||||
|
||||
for method_code in platega_methods:
|
||||
info = definitions.get(method_code, {})
|
||||
options.append(
|
||||
MiniAppPaymentOption(
|
||||
id=str(method_code),
|
||||
icon=info.get("icon") or ("🏦" if method_code == 2 else "💳"),
|
||||
title_key=f"topup.method.platega.option.{method_code}.title",
|
||||
description_key=f"topup.method.platega.option.{method_code}.description",
|
||||
title=info.get("title") or info.get("name") or f"Platega {method_code}",
|
||||
description=info.get("description") or info.get("name"),
|
||||
)
|
||||
)
|
||||
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="platega",
|
||||
icon="💳",
|
||||
requires_amount=True,
|
||||
currency=settings.PLATEGA_CURRENCY,
|
||||
min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS,
|
||||
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||
options=options,
|
||||
)
|
||||
)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
rate = await _get_usd_to_rub_rate()
|
||||
min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate)
|
||||
@@ -768,10 +891,11 @@ async def get_payment_methods(
|
||||
"yookassa": 3,
|
||||
"mulenpay": 4,
|
||||
"pal24": 5,
|
||||
"wata": 6,
|
||||
"cryptobot": 7,
|
||||
"heleket": 8,
|
||||
"tribute": 9,
|
||||
"platega": 6,
|
||||
"wata": 7,
|
||||
"cryptobot": 8,
|
||||
"heleket": 9,
|
||||
"tribute": 10,
|
||||
}
|
||||
methods.sort(key=lambda item: order_map.get(item.id, 99))
|
||||
|
||||
@@ -948,6 +1072,54 @@ async def create_payment_link(
|
||||
},
|
||||
)
|
||||
|
||||
if method == "platega":
|
||||
if not settings.is_platega_enabled() or not settings.get_platega_active_methods():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||
if amount_kopeks is None or amount_kopeks <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
|
||||
|
||||
active_methods = settings.get_platega_active_methods()
|
||||
method_option = payload.payment_option or str(active_methods[0])
|
||||
try:
|
||||
method_code = int(str(method_option).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid Platega payment option")
|
||||
|
||||
if method_code not in active_methods:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Selected Platega method is unavailable")
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=user.language or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_code=method_code,
|
||||
)
|
||||
|
||||
redirect_url = result.get("redirect_url") if result else None
|
||||
if not result or not redirect_url:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||||
|
||||
return MiniAppPaymentCreateResponse(
|
||||
method=method,
|
||||
payment_url=redirect_url,
|
||||
amount_kopeks=amount_kopeks,
|
||||
extra={
|
||||
"local_payment_id": result.get("local_payment_id"),
|
||||
"payment_id": result.get("transaction_id"),
|
||||
"correlation_id": result.get("correlation_id"),
|
||||
"selected_option": str(method_code),
|
||||
"payload": result.get("payload"),
|
||||
"requested_at": _current_request_timestamp(),
|
||||
},
|
||||
)
|
||||
|
||||
if method == "wata":
|
||||
if not settings.is_wata_enabled():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||
@@ -1242,6 +1414,8 @@ async def _resolve_payment_status_entry(
|
||||
)
|
||||
if method == "mulenpay":
|
||||
return await _resolve_mulenpay_payment_status(payment_service, db, user, query)
|
||||
if method == "platega":
|
||||
return await _resolve_platega_payment_status(payment_service, db, user, query)
|
||||
if method == "wata":
|
||||
return await _resolve_wata_payment_status(payment_service, db, user, query)
|
||||
if method == "pal24":
|
||||
@@ -1394,6 +1568,85 @@ async def _resolve_mulenpay_payment_status(
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_platega_payment_status(
|
||||
payment_service: PaymentService,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
query: MiniAppPaymentStatusQuery,
|
||||
) -> MiniAppPaymentStatusResult:
|
||||
from app.database.crud.platega import (
|
||||
get_platega_payment_by_correlation_id,
|
||||
get_platega_payment_by_id,
|
||||
get_platega_payment_by_transaction_id,
|
||||
)
|
||||
|
||||
payment = None
|
||||
local_id = query.local_payment_id
|
||||
if local_id:
|
||||
payment = await get_platega_payment_by_id(db, local_id)
|
||||
|
||||
if not payment and query.payment_id:
|
||||
payment = await get_platega_payment_by_transaction_id(db, query.payment_id)
|
||||
|
||||
if not payment and query.payload:
|
||||
correlation = str(query.payload).replace("platega:", "")
|
||||
payment = await get_platega_payment_by_correlation_id(db, correlation)
|
||||
|
||||
if not payment or payment.user_id != user.id:
|
||||
return MiniAppPaymentStatusResult(
|
||||
method="platega",
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
amount_kopeks=query.amount_kopeks,
|
||||
message="Payment not found",
|
||||
extra={
|
||||
"local_payment_id": query.local_payment_id,
|
||||
"payment_id": query.payment_id,
|
||||
"payload": query.payload,
|
||||
"started_at": query.started_at,
|
||||
},
|
||||
)
|
||||
|
||||
status_info = await payment_service.get_platega_payment_status(db, payment.id)
|
||||
refreshed_payment = (status_info or {}).get("payment") or payment
|
||||
|
||||
status_raw = (status_info or {}).get("status") or getattr(payment, "status", None)
|
||||
is_paid_flag = bool((status_info or {}).get("is_paid") or getattr(payment, "is_paid", False))
|
||||
status_value = _classify_status(status_raw, is_paid_flag)
|
||||
|
||||
completed_at = (
|
||||
getattr(refreshed_payment, "paid_at", None)
|
||||
or getattr(refreshed_payment, "updated_at", None)
|
||||
or getattr(refreshed_payment, "created_at", None)
|
||||
)
|
||||
|
||||
extra: Dict[str, Any] = {
|
||||
"local_payment_id": refreshed_payment.id,
|
||||
"payment_id": refreshed_payment.platega_transaction_id,
|
||||
"correlation_id": refreshed_payment.correlation_id,
|
||||
"status": status_raw,
|
||||
"is_paid": getattr(refreshed_payment, "is_paid", False),
|
||||
"payload": query.payload,
|
||||
"started_at": query.started_at,
|
||||
}
|
||||
|
||||
if status_info and status_info.get("remote"):
|
||||
extra["remote"] = status_info.get("remote")
|
||||
|
||||
return MiniAppPaymentStatusResult(
|
||||
method="platega",
|
||||
status=status_value,
|
||||
is_paid=status_value == "paid",
|
||||
amount_kopeks=refreshed_payment.amount_kopeks,
|
||||
currency=refreshed_payment.currency,
|
||||
completed_at=completed_at,
|
||||
transaction_id=refreshed_payment.transaction_id,
|
||||
external_id=refreshed_payment.platega_transaction_id,
|
||||
message=None,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_wata_payment_status(
|
||||
payment_service: PaymentService,
|
||||
db: AsyncSession,
|
||||
@@ -2447,7 +2700,12 @@ async def _build_referral_info(
|
||||
minimum_topup_kopeks = int(referral_settings.get("minimum_topup_kopeks") or 0)
|
||||
first_topup_bonus_kopeks = int(referral_settings.get("first_topup_bonus_kopeks") or 0)
|
||||
inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0)
|
||||
commission_percent = float(referral_settings.get("commission_percent") or 0)
|
||||
commission_percent = float(
|
||||
get_effective_referral_commission_percent(user)
|
||||
if user
|
||||
else referral_settings.get("commission_percent")
|
||||
or 0
|
||||
)
|
||||
|
||||
terms = MiniAppReferralTerms(
|
||||
minimum_topup_kopeks=minimum_topup_kopeks,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased, selectinload
|
||||
|
||||
from app.database.crud.referral import get_user_referral_stats
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id,
|
||||
get_user_by_telegram_id,
|
||||
update_user,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_effective_referral_commission_percent,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.partners import (
|
||||
PartnerReferralItem,
|
||||
PartnerReferralList,
|
||||
PartnerReferralCommissionUpdate,
|
||||
PartnerReferrerDetail,
|
||||
PartnerReferrerItem,
|
||||
PartnerReferrerListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _apply_search_filter(query, search: str):
|
||||
search_lower = f"%{search.lower()}%"
|
||||
conditions = [
|
||||
func.lower(User.username).like(search_lower),
|
||||
func.lower(User.first_name).like(search_lower),
|
||||
func.lower(User.last_name).like(search_lower),
|
||||
func.lower(User.referral_code).like(search_lower),
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
conditions.append(User.id == int(search))
|
||||
|
||||
return query.where(or_(*conditions))
|
||||
|
||||
|
||||
def _serialize_referrer(user: User, stats: dict) -> PartnerReferrerItem:
|
||||
total_earned_kopeks = int(stats.get("total_earned_kopeks") or 0)
|
||||
month_earned_kopeks = int(stats.get("month_earned_kopeks") or 0)
|
||||
|
||||
return PartnerReferrerItem(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
referral_code=user.referral_code,
|
||||
referral_commission_percent=getattr(user, "referral_commission_percent", None),
|
||||
effective_referral_commission_percent=get_effective_referral_commission_percent(user),
|
||||
invited_count=int(stats.get("invited_count") or 0),
|
||||
active_referrals=int(stats.get("active_referrals") or 0),
|
||||
total_earned_kopeks=total_earned_kopeks,
|
||||
total_earned_rubles=round(total_earned_kopeks / 100, 2),
|
||||
month_earned_kopeks=month_earned_kopeks,
|
||||
month_earned_rubles=round(month_earned_kopeks / 100, 2),
|
||||
created_at=user.created_at,
|
||||
last_activity=user.last_activity,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_referral_item(referral: dict) -> PartnerReferralItem:
|
||||
balance_kopeks = int(referral.get("balance_kopeks") or 0)
|
||||
total_earned_kopeks = int(referral.get("total_earned_kopeks") or 0)
|
||||
|
||||
return PartnerReferralItem(
|
||||
id=int(referral.get("id")),
|
||||
telegram_id=int(referral.get("telegram_id")),
|
||||
full_name=str(referral.get("full_name")),
|
||||
username=referral.get("username"),
|
||||
created_at=referral.get("created_at"),
|
||||
last_activity=referral.get("last_activity"),
|
||||
has_made_first_topup=bool(referral.get("has_made_first_topup", False)),
|
||||
balance_kopeks=balance_kopeks,
|
||||
balance_rubles=round(balance_kopeks / 100, 2),
|
||||
total_earned_kopeks=total_earned_kopeks,
|
||||
total_earned_rubles=round(total_earned_kopeks / 100, 2),
|
||||
topups_count=int(referral.get("topups_count") or 0),
|
||||
days_since_registration=int(referral.get("days_since_registration") or 0),
|
||||
days_since_activity=referral.get("days_since_activity"),
|
||||
status=str(referral.get("status") or "inactive"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers", response_model=PartnerReferrerListResponse)
|
||||
async def list_referrers(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: Optional[str] = Query(default=None),
|
||||
) -> PartnerReferrerListResponse:
|
||||
referral_alias = aliased(User)
|
||||
has_referrals = (
|
||||
select(referral_alias.id)
|
||||
.where(referral_alias.referred_by_id == User.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
base_query = select(User).options(selectinload(User.referrer)).where(
|
||||
or_(User.referral_code.isnot(None), has_referrals)
|
||||
)
|
||||
|
||||
if search:
|
||||
base_query = _apply_search_filter(base_query, search)
|
||||
|
||||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||
total = await db.scalar(total_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
referrers = result.scalars().unique().all()
|
||||
|
||||
items: list[PartnerReferrerItem] = []
|
||||
for referrer in referrers:
|
||||
stats = await get_user_referral_stats(db, referrer.id)
|
||||
items.append(_serialize_referrer(referrer, stats))
|
||||
|
||||
return PartnerReferrerListResponse(
|
||||
items=items,
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}", response_model=PartnerReferrerDetail)
|
||||
async def get_referrer_detail(
|
||||
user_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PartnerReferrerDetail:
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
stats = await get_user_referral_stats(db, user.id)
|
||||
referrer_item = _serialize_referrer(user, stats)
|
||||
|
||||
referrals_data = await get_detailed_referral_list(db, user.id, limit=limit, offset=offset)
|
||||
referral_items = [
|
||||
_serialize_referral_item(referral) for referral in referrals_data.get("referrals", [])
|
||||
]
|
||||
|
||||
referrals_list = PartnerReferralList(
|
||||
items=referral_items,
|
||||
total=int(referrals_data.get("total_count") or 0),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
has_next=bool(referrals_data.get("has_next")),
|
||||
has_prev=bool(referrals_data.get("has_prev")),
|
||||
current_page=int(referrals_data.get("current_page") or 1),
|
||||
total_pages=int(referrals_data.get("total_pages") or 1),
|
||||
)
|
||||
|
||||
return PartnerReferrerDetail(referrer=referrer_item, referrals=referrals_list)
|
||||
|
||||
|
||||
@router.patch("/referrers/{user_id}/commission", response_model=PartnerReferrerItem)
|
||||
async def update_referrer_commission(
|
||||
user_id: int,
|
||||
payload: PartnerReferralCommissionUpdate,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PartnerReferrerItem:
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
await update_user(
|
||||
db,
|
||||
user,
|
||||
referral_commission_percent=payload.referral_commission_percent,
|
||||
)
|
||||
|
||||
stats = await get_user_referral_stats(db, user.id)
|
||||
return _serialize_referrer(user, stats)
|
||||
@@ -11,16 +11,20 @@ from app.database.crud.discount_offer import (
|
||||
list_discount_offers,
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||
from app.database.crud.promo_offer_log import list_promo_offer_logs
|
||||
from app.database.crud.promo_offer_template import (
|
||||
get_promo_offer_template_by_id,
|
||||
list_promo_offer_templates,
|
||||
update_promo_offer_template,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, Subscription, User
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promo_offers import (
|
||||
PromoOfferBroadcastRequest,
|
||||
PromoOfferBroadcastResponse,
|
||||
PromoOfferCreateRequest,
|
||||
PromoOfferListResponse,
|
||||
PromoOfferLogListResponse,
|
||||
@@ -137,6 +141,14 @@ def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse:
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
|
||||
normalized = target.strip().lower()
|
||||
if normalized.startswith("custom_"):
|
||||
criteria = normalized[len("custom_"):]
|
||||
return await get_custom_users(db, criteria)
|
||||
return await get_target_users(db, normalized)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoOfferListResponse)
|
||||
async def list_promo_offers(
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -144,20 +156,35 @@ async def list_promo_offers(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: Optional[int] = Query(None, ge=1),
|
||||
telegram_id: Optional[int] = Query(None, ge=1),
|
||||
notification_type: Optional[str] = Query(None, min_length=1),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
) -> PromoOfferListResponse:
|
||||
resolved_user_id = user_id
|
||||
if telegram_id is not None:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
if resolved_user_id and resolved_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="telegram_id does not match the provided user_id",
|
||||
)
|
||||
|
||||
resolved_user_id = user.id
|
||||
|
||||
offers = await list_discount_offers(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
user_id=resolved_user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
total = await count_discount_offers(
|
||||
db,
|
||||
user_id=user_id,
|
||||
user_id=resolved_user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
@@ -187,7 +214,26 @@ async def create_promo_offer(
|
||||
if not payload.effect_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
|
||||
|
||||
user = await db.get(User, payload.user_id)
|
||||
target_user_id = payload.user_id
|
||||
user: Optional[User] = None
|
||||
if payload.telegram_id is not None:
|
||||
user = await get_user_by_telegram_id(db, payload.telegram_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
if target_user_id and target_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Provided user_id does not match telegram_id",
|
||||
)
|
||||
|
||||
target_user_id = user.id
|
||||
|
||||
if target_user_id is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "user_id or telegram_id is required")
|
||||
|
||||
if user is None:
|
||||
user = await db.get(User, target_user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
@@ -195,12 +241,12 @@ async def create_promo_offer(
|
||||
subscription = await db.get(Subscription, payload.subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
if subscription.user_id != payload.user_id:
|
||||
if subscription.user_id != target_user_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription does not belong to the user")
|
||||
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
user_id=target_user_id,
|
||||
subscription_id=payload.subscription_id,
|
||||
notification_type=payload.notification_type.strip(),
|
||||
discount_percent=payload.discount_percent,
|
||||
@@ -215,6 +261,101 @@ async def create_promo_offer(
|
||||
return _serialize_offer(offer)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/broadcast",
|
||||
response_model=PromoOfferBroadcastResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def broadcast_promo_offers(
|
||||
payload: PromoOfferBroadcastRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferBroadcastResponse:
|
||||
if payload.discount_percent < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
|
||||
if payload.bonus_amount_kopeks < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
|
||||
if payload.valid_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
|
||||
if not payload.notification_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty")
|
||||
if not payload.effect_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
|
||||
|
||||
recipients: dict[int, User] = {}
|
||||
|
||||
target = payload.target
|
||||
if target:
|
||||
users = await _resolve_target_users(db, target)
|
||||
recipients.update({user.id: user for user in users if user and user.id})
|
||||
|
||||
target_user_id = payload.user_id
|
||||
user: Optional[User] = None
|
||||
if payload.telegram_id is not None:
|
||||
user = await get_user_by_telegram_id(db, payload.telegram_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
if target_user_id and target_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Provided user_id does not match telegram_id",
|
||||
)
|
||||
|
||||
target_user_id = user.id
|
||||
|
||||
if target_user_id is not None:
|
||||
if user is None:
|
||||
user = await db.get(User, target_user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
recipients[target_user_id] = user
|
||||
|
||||
if not recipients:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Пустая аудитория: укажите target или конкретного пользователя",
|
||||
)
|
||||
|
||||
if payload.subscription_id is not None:
|
||||
if len(recipients) > 1:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"subscription_id можно использовать только при отправке одному пользователю",
|
||||
)
|
||||
sole_user = next(iter(recipients.values()))
|
||||
subscription = await db.get(Subscription, payload.subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
if subscription.user_id != sole_user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Subscription does not belong to the user",
|
||||
)
|
||||
|
||||
created_offers = 0
|
||||
for user in recipients.values():
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=payload.subscription_id,
|
||||
notification_type=payload.notification_type.strip(),
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
valid_hours=payload.valid_hours,
|
||||
effect_type=payload.effect_type,
|
||||
extra_data=payload.extra_data,
|
||||
)
|
||||
if offer:
|
||||
created_offers += 1
|
||||
|
||||
return PromoOfferBroadcastResponse(
|
||||
created_offers=created_offers,
|
||||
user_ids=list(recipients.keys()),
|
||||
target=payload.target,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=PromoOfferLogListResponse)
|
||||
async def get_promo_offer_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
|
||||
@@ -93,6 +93,19 @@ def _serialize_node(node_data: Dict[str, Any]) -> RemnaWaveNode:
|
||||
users_online=node_data.get("users_online"),
|
||||
traffic_used_bytes=node_data.get("traffic_used_bytes"),
|
||||
traffic_limit_bytes=node_data.get("traffic_limit_bytes"),
|
||||
last_status_change=_parse_last_updated(node_data.get("last_status_change")),
|
||||
last_status_message=node_data.get("last_status_message"),
|
||||
xray_uptime=node_data.get("xray_uptime"),
|
||||
is_traffic_tracking_active=bool(node_data.get("is_traffic_tracking_active", False)),
|
||||
traffic_reset_day=node_data.get("traffic_reset_day"),
|
||||
notify_percent=node_data.get("notify_percent"),
|
||||
consumption_multiplier=float(node_data.get("consumption_multiplier", 1.0)),
|
||||
cpu_count=node_data.get("cpu_count"),
|
||||
cpu_model=node_data.get("cpu_model"),
|
||||
total_ram=node_data.get("total_ram"),
|
||||
created_at=_parse_last_updated(node_data.get("created_at")),
|
||||
updated_at=_parse_last_updated(node_data.get("updated_at")),
|
||||
provider_uuid=node_data.get("provider_uuid"),
|
||||
)
|
||||
|
||||
|
||||
@@ -291,9 +304,13 @@ async def create_squad(
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = await service.create_squad(payload.name, payload.inbound_uuids)
|
||||
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
|
||||
|
||||
success = squad_uuid is not None
|
||||
detail = "Сквад успешно создан" if success else "Не удалось создать сквад"
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail)
|
||||
data = {"uuid": squad_uuid} if success else None
|
||||
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail, data=data)
|
||||
|
||||
|
||||
@router.patch("/squads/{squad_uuid}", response_model=RemnaWaveOperationResponse)
|
||||
|
||||
+214
-39
@@ -2,6 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.crud.referral import get_referral_statistics
|
||||
from app.database.crud.subscription import get_subscriptions_statistics, get_trial_statistics
|
||||
from app.database.crud.transaction import get_transactions_statistics
|
||||
from app.database.crud.user import get_users_statistics
|
||||
|
||||
from fastapi import APIRouter, Depends, Security
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,43 +27,11 @@ from ..dependencies import get_db_session, require_api_token
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/overview",
|
||||
summary="Общая статистика",
|
||||
response_description="Агрегированные показатели пользователей, подписок, саппорта и платежей",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"users": {
|
||||
"total": 12345,
|
||||
"active": 9876,
|
||||
"blocked": 321,
|
||||
"balance_kopeks": 1234567,
|
||||
"balance_rubles": 12345.67,
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": 4321,
|
||||
"expired": 210,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": 42,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": 654321,
|
||||
"today_rubles": 6543.21,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def stats_overview(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, object]:
|
||||
def _kopeks_to_rubles(value: int | float | None) -> float:
|
||||
return round((value or 0) / 100, 2)
|
||||
|
||||
|
||||
async def _get_overview(db: AsyncSession) -> dict[str, object]:
|
||||
total_users = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
active_users = await db.scalar(
|
||||
select(func.count()).select_from(User).where(User.status == UserStatus.ACTIVE.value)
|
||||
@@ -103,7 +76,7 @@ async def stats_overview(
|
||||
"active": active_users,
|
||||
"blocked": blocked_users,
|
||||
"balance_kopeks": int(total_balance_kopeks),
|
||||
"balance_rubles": round(total_balance_kopeks / 100, 2),
|
||||
"balance_rubles": _kopeks_to_rubles(total_balance_kopeks),
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": active_subscriptions,
|
||||
@@ -114,6 +87,208 @@ async def stats_overview(
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": int(today_transactions),
|
||||
"today_rubles": round(today_transactions / 100, 2),
|
||||
"today_rubles": _kopeks_to_rubles(today_transactions),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/overview",
|
||||
summary="Общая статистика",
|
||||
response_description="Агрегированные показатели пользователей, подписок, саппорта и платежей",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"users": {
|
||||
"total": 12345,
|
||||
"active": 9876,
|
||||
"blocked": 321,
|
||||
"balance_kopeks": 1234567,
|
||||
"balance_rubles": 12345.67,
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": 4321,
|
||||
"expired": 210,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": 42,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": 654321,
|
||||
"today_rubles": 6543.21,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def stats_overview(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, object]:
|
||||
return await _get_overview(db)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/full",
|
||||
summary="Полная статистика",
|
||||
response_description="Расширенные показатели пользователей, подписок, платежей и рефералов",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"overview": {
|
||||
"users": {
|
||||
"total": 12345,
|
||||
"active": 9876,
|
||||
"blocked": 321,
|
||||
"balance_kopeks": 1234567,
|
||||
"balance_rubles": 12345.67,
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": 4321,
|
||||
"expired": 210,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": 42,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": 654321,
|
||||
"today_rubles": 6543.21,
|
||||
},
|
||||
},
|
||||
"users": {
|
||||
"total_users": 12345,
|
||||
"active_users": 9876,
|
||||
"blocked_users": 321,
|
||||
"new_today": 12,
|
||||
"new_week": 345,
|
||||
"new_month": 1234,
|
||||
},
|
||||
"subscriptions": {
|
||||
"total_subscriptions": 9876,
|
||||
"active_subscriptions": 8765,
|
||||
"trial_subscriptions": 321,
|
||||
"paid_subscriptions": 8444,
|
||||
"purchased_today": 12,
|
||||
"purchased_week": 210,
|
||||
"purchased_month": 765,
|
||||
"trial_to_paid_conversion": 42.5,
|
||||
"renewals_count": 123,
|
||||
"trial_statistics": {
|
||||
"used_trials": 555,
|
||||
"active_trials": 210,
|
||||
"resettable_trials": 42,
|
||||
},
|
||||
},
|
||||
"transactions": {
|
||||
"period": {
|
||||
"start_date": "2024-06-01T00:00:00Z",
|
||||
"end_date": "2024-06-30T23:59:59Z",
|
||||
},
|
||||
"totals": {
|
||||
"income_kopeks": 1234567,
|
||||
"income_rubles": 12345.67,
|
||||
"expenses_kopeks": 21000,
|
||||
"expenses_rubles": 210,
|
||||
"profit_kopeks": 1213567,
|
||||
"profit_rubles": 12135.67,
|
||||
"subscription_income_kopeks": 987654,
|
||||
"subscription_income_rubles": 9876.54,
|
||||
},
|
||||
"today": {
|
||||
"transactions_count": 42,
|
||||
"income_kopeks": 654321,
|
||||
"income_rubles": 6543.21,
|
||||
},
|
||||
"by_type": {
|
||||
"deposit": {"count": 123, "amount": 1234567},
|
||||
"withdrawal": {"count": 10, "amount": 21000},
|
||||
},
|
||||
"by_payment_method": {
|
||||
"card": {"count": 100, "amount": 1000000}
|
||||
},
|
||||
},
|
||||
"referrals": {
|
||||
"users_with_referrals": 4321,
|
||||
"active_referrers": 123,
|
||||
"total_paid_kopeks": 765432,
|
||||
"total_paid_rubles": 7654.32,
|
||||
"today_earnings_kopeks": 12345,
|
||||
"today_earnings_rubles": 123.45,
|
||||
"week_earnings_kopeks": 23456,
|
||||
"week_earnings_rubles": 234.56,
|
||||
"month_earnings_kopeks": 34567,
|
||||
"month_earnings_rubles": 345.67,
|
||||
"top_referrers": [
|
||||
{
|
||||
"user_id": 123456789,
|
||||
"display_name": "@testuser",
|
||||
"username": "testuser",
|
||||
"telegram_id": 123456789,
|
||||
"total_earned_kopeks": 54321,
|
||||
"referrals_count": 42,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def stats_full(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, object]:
|
||||
overview = await _get_overview(db)
|
||||
|
||||
users_stats = await get_users_statistics(db)
|
||||
subscriptions_stats = await get_subscriptions_statistics(db)
|
||||
trial_stats = await get_trial_statistics(db)
|
||||
transactions_stats = await get_transactions_statistics(db)
|
||||
referral_stats = await get_referral_statistics(db)
|
||||
|
||||
transactions_totals = transactions_stats.get("totals", {})
|
||||
transactions_today = transactions_stats.get("today", {})
|
||||
|
||||
transactions_totals = {
|
||||
**transactions_totals,
|
||||
"income_rubles": _kopeks_to_rubles(transactions_totals.get("income_kopeks")),
|
||||
"expenses_rubles": _kopeks_to_rubles(transactions_totals.get("expenses_kopeks")),
|
||||
"profit_rubles": _kopeks_to_rubles(transactions_totals.get("profit_kopeks")),
|
||||
"subscription_income_rubles": _kopeks_to_rubles(
|
||||
transactions_totals.get("subscription_income_kopeks")
|
||||
),
|
||||
}
|
||||
|
||||
transactions_today = {
|
||||
**transactions_today,
|
||||
"income_rubles": _kopeks_to_rubles(transactions_today.get("income_kopeks")),
|
||||
}
|
||||
|
||||
referral_stats = {
|
||||
**referral_stats,
|
||||
"total_paid_rubles": _kopeks_to_rubles(referral_stats.get("total_paid_kopeks")),
|
||||
"today_earnings_rubles": _kopeks_to_rubles(
|
||||
referral_stats.get("today_earnings_kopeks")
|
||||
),
|
||||
"week_earnings_rubles": _kopeks_to_rubles(referral_stats.get("week_earnings_kopeks")),
|
||||
"month_earnings_rubles": _kopeks_to_rubles(referral_stats.get("month_earnings_kopeks")),
|
||||
}
|
||||
|
||||
return {
|
||||
"overview": overview,
|
||||
"users": users_stats,
|
||||
"subscriptions": {**subscriptions_stats, "trial_statistics": trial_stats},
|
||||
"transactions": {
|
||||
**transactions_stats,
|
||||
"totals": transactions_totals,
|
||||
"today": transactions_today,
|
||||
},
|
||||
"referrals": referral_stats,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Subscription, SubscriptionEvent, Transaction, User
|
||||
from app.database.crud.subscription_event import (
|
||||
create_subscription_event,
|
||||
list_subscription_events,
|
||||
)
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.subscription_events import (
|
||||
SubscriptionEventCreate,
|
||||
SubscriptionEventListResponse,
|
||||
SubscriptionEventResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _get_user_or_error(db: AsyncSession, user_id: int) -> User:
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
async def _ensure_subscription_exists(
|
||||
db: AsyncSession, subscription_id: Optional[int]
|
||||
) -> None:
|
||||
if not subscription_id:
|
||||
return
|
||||
|
||||
subscription = await db.get(Subscription, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Subscription not found",
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_transaction_exists(db: AsyncSession, transaction_id: Optional[int]) -> None:
|
||||
if not transaction_id:
|
||||
return
|
||||
|
||||
transaction_exists = await db.scalar(
|
||||
select(Transaction.id).where(Transaction.id == transaction_id)
|
||||
)
|
||||
if not transaction_exists:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Transaction not found",
|
||||
)
|
||||
|
||||
|
||||
def _serialize_event(event: SubscriptionEvent) -> SubscriptionEventResponse:
|
||||
user = event.user
|
||||
|
||||
extra = event.extra or {}
|
||||
|
||||
if event.event_type == "promocode_activation":
|
||||
extra = {**extra}
|
||||
extra.setdefault("balance_before_kopeks", None)
|
||||
extra.setdefault("balance_after_kopeks", None)
|
||||
|
||||
return SubscriptionEventResponse(
|
||||
id=event.id,
|
||||
event_type=event.event_type,
|
||||
user_id=event.user_id,
|
||||
user_full_name=user.full_name if user else "",
|
||||
user_username=user.username if user else None,
|
||||
user_telegram_id=user.telegram_id if user else 0,
|
||||
subscription_id=event.subscription_id,
|
||||
transaction_id=event.transaction_id,
|
||||
amount_kopeks=event.amount_kopeks,
|
||||
currency=event.currency,
|
||||
message=event.message,
|
||||
occurred_at=event.occurred_at,
|
||||
created_at=event.created_at,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=SubscriptionEventResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def receive_subscription_event(
|
||||
payload: SubscriptionEventCreate,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionEventResponse:
|
||||
user = await _get_user_or_error(db, payload.user_id)
|
||||
await _ensure_subscription_exists(db, payload.subscription_id)
|
||||
await _ensure_transaction_exists(db, payload.transaction_id)
|
||||
|
||||
event = await create_subscription_event(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
event_type=payload.event_type,
|
||||
subscription_id=payload.subscription_id,
|
||||
transaction_id=payload.transaction_id,
|
||||
amount_kopeks=payload.amount_kopeks,
|
||||
currency=payload.currency,
|
||||
message=payload.message,
|
||||
occurred_at=payload.occurred_at,
|
||||
extra=payload.extra or None,
|
||||
)
|
||||
|
||||
await db.refresh(event, attribute_names=["user"])
|
||||
|
||||
event.user = user
|
||||
|
||||
return _serialize_event(event)
|
||||
|
||||
|
||||
@router.get("", response_model=SubscriptionEventListResponse)
|
||||
async def list_subscription_event_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
event_types: Optional[Iterable[str]] = Query(default=None, alias="event_type"),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
) -> SubscriptionEventListResponse:
|
||||
events, total = await list_subscription_events(
|
||||
db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
event_types=event_types,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return SubscriptionEventListResponse(
|
||||
items=[_serialize_event(event) for event in events],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from aiogram import Bot
|
||||
@@ -27,6 +29,7 @@ from ..schemas.tickets import (
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
||||
@@ -248,6 +251,7 @@ async def reply_to_ticket(
|
||||
async def get_ticket_message_media(
|
||||
ticket_id: int,
|
||||
message_id: int,
|
||||
request: Request,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketMediaResponse:
|
||||
@@ -262,10 +266,25 @@ async def get_ticket_message_media(
|
||||
if not message.has_media or not message.media_file_id or not message.media_type:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media not found for this message")
|
||||
|
||||
media_url: Optional[str] = None
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
try:
|
||||
file = await bot.get_file(message.media_file_id)
|
||||
if file.file_path:
|
||||
media_url = str(request.url_for("download_media", file_id=message.media_file_id))
|
||||
except Exception as error:
|
||||
logger.warning("Failed to resolve media URL for ticket %s message %s: %s", ticket_id, message_id, error)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
return TicketMediaResponse(
|
||||
id=message.id,
|
||||
ticket_id=ticket.id,
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
media_url=media_url,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.user_message import (
|
||||
create_user_message,
|
||||
delete_user_message,
|
||||
get_all_user_messages,
|
||||
get_user_message_by_id,
|
||||
get_user_messages_count,
|
||||
toggle_user_message_status,
|
||||
update_user_message,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.user_messages import (
|
||||
UserMessageCreateRequest,
|
||||
UserMessageListResponse,
|
||||
UserMessageResponse,
|
||||
UserMessageUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(message) -> UserMessageResponse:
|
||||
return UserMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text,
|
||||
is_active=message.is_active,
|
||||
sort_order=message.sort_order,
|
||||
created_by=message.created_by,
|
||||
created_at=message.created_at,
|
||||
updated_at=message.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=UserMessageListResponse)
|
||||
async def list_user_messages(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
include_inactive: bool = Query(True, description="Включать неактивные сообщения"),
|
||||
) -> UserMessageListResponse:
|
||||
total = await get_user_messages_count(db, include_inactive=include_inactive)
|
||||
messages = await get_all_user_messages(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
return UserMessageListResponse(
|
||||
items=[_serialize(message) for message in messages],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user_message_endpoint(
|
||||
payload: UserMessageCreateRequest,
|
||||
token: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserMessageResponse:
|
||||
created_by = getattr(token, "id", None)
|
||||
try:
|
||||
message = await create_user_message(
|
||||
db,
|
||||
message_text=payload.message_text,
|
||||
created_by=created_by,
|
||||
is_active=payload.is_active,
|
||||
sort_order=payload.sort_order,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
|
||||
|
||||
return _serialize(message)
|
||||
|
||||
|
||||
@router.patch("/{message_id}", response_model=UserMessageResponse)
|
||||
async def update_user_message_endpoint(
|
||||
message_id: int,
|
||||
payload: UserMessageUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserMessageResponse:
|
||||
update_payload = payload.dict(exclude_unset=True)
|
||||
try:
|
||||
message = await update_user_message(db, message_id, **update_payload)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found")
|
||||
|
||||
return _serialize(message)
|
||||
|
||||
|
||||
@router.post("/{message_id}/toggle", response_model=UserMessageResponse)
|
||||
async def toggle_user_message_endpoint(
|
||||
message_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserMessageResponse:
|
||||
message = await toggle_user_message_status(db, message_id)
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found")
|
||||
|
||||
return _serialize(message)
|
||||
|
||||
|
||||
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user_message_endpoint(
|
||||
message_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
if not message:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found")
|
||||
|
||||
await delete_user_message(db, message_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.welcome_text import (
|
||||
count_welcome_texts,
|
||||
create_welcome_text,
|
||||
delete_welcome_text,
|
||||
get_welcome_text_by_id,
|
||||
list_welcome_texts,
|
||||
update_welcome_text,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.welcome_texts import (
|
||||
WelcomeTextCreateRequest,
|
||||
WelcomeTextListResponse,
|
||||
WelcomeTextResponse,
|
||||
WelcomeTextUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(text) -> WelcomeTextResponse:
|
||||
return WelcomeTextResponse(
|
||||
id=text.id,
|
||||
text=text.text_content,
|
||||
is_active=text.is_active,
|
||||
is_enabled=text.is_enabled,
|
||||
created_by=text.created_by,
|
||||
created_at=text.created_at,
|
||||
updated_at=text.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=WelcomeTextListResponse)
|
||||
async def list_welcome_texts_endpoint(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
include_inactive: bool = Query(True, description="Включать неактивные тексты"),
|
||||
) -> WelcomeTextListResponse:
|
||||
total = await count_welcome_texts(db, include_inactive=include_inactive)
|
||||
records = await list_welcome_texts(
|
||||
db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
return WelcomeTextListResponse(
|
||||
items=[_serialize(item) for item in records],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_welcome_text_endpoint(
|
||||
payload: WelcomeTextCreateRequest,
|
||||
token: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WelcomeTextResponse:
|
||||
created_by = getattr(token, "id", None)
|
||||
record = await create_welcome_text(
|
||||
db,
|
||||
text_content=payload.text,
|
||||
created_by=created_by,
|
||||
is_enabled=payload.is_enabled,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
|
||||
return _serialize(record)
|
||||
|
||||
|
||||
@router.get("/{welcome_text_id}", response_model=WelcomeTextResponse)
|
||||
async def get_welcome_text_endpoint(
|
||||
welcome_text_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WelcomeTextResponse:
|
||||
record = await get_welcome_text_by_id(db, welcome_text_id)
|
||||
if not record:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found")
|
||||
|
||||
return _serialize(record)
|
||||
|
||||
|
||||
@router.patch("/{welcome_text_id}", response_model=WelcomeTextResponse)
|
||||
async def update_welcome_text_endpoint(
|
||||
welcome_text_id: int,
|
||||
payload: WelcomeTextUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WelcomeTextResponse:
|
||||
record = await get_welcome_text_by_id(db, welcome_text_id)
|
||||
if not record:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found")
|
||||
|
||||
update_payload = payload.dict(exclude_unset=True)
|
||||
if "text" in update_payload:
|
||||
update_payload["text_content"] = update_payload.pop("text")
|
||||
updated = await update_welcome_text(db, record, **update_payload)
|
||||
return _serialize(updated)
|
||||
|
||||
|
||||
@router.delete("/{welcome_text_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_welcome_text_endpoint(
|
||||
welcome_text_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
record = await get_welcome_text_by_id(db, welcome_text_id)
|
||||
if not record:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found")
|
||||
|
||||
await delete_welcome_text(db, record)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MediaUploadResponse(BaseModel):
|
||||
media_type: str = Field(description="Тип загруженного файла (photo, video, document)")
|
||||
file_id: str = Field(description="Telegram file_id загруженного файла")
|
||||
file_unique_id: Optional[str] = Field(
|
||||
default=None, description="Уникальный идентификатор файла"
|
||||
)
|
||||
media_url: Optional[str] = Field(
|
||||
default=None, description="Прямая ссылка на файл для предпросмотра"
|
||||
)
|
||||
@@ -17,6 +17,14 @@ class MiniAppSubscriptionRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
|
||||
class MiniAppMaintenanceStatusResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
is_active: bool = Field(..., alias="isActive")
|
||||
message: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionUser(BaseModel):
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
@@ -373,6 +381,17 @@ class MiniAppPaymentIntegrationType(str, Enum):
|
||||
REDIRECT = "redirect"
|
||||
|
||||
|
||||
class MiniAppPaymentOption(BaseModel):
|
||||
id: str
|
||||
icon: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
title_key: Optional[str] = Field(default=None, alias="titleKey")
|
||||
description_key: Optional[str] = Field(default=None, alias="descriptionKey")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppPaymentIframeConfig(BaseModel):
|
||||
expected_origin: str
|
||||
|
||||
@@ -402,6 +421,7 @@ class MiniAppPaymentMethod(BaseModel):
|
||||
max_amount_kopeks: Optional[int] = None
|
||||
amount_step_kopeks: Optional[int] = None
|
||||
integration_type: MiniAppPaymentIntegrationType
|
||||
options: List[MiniAppPaymentOption] = Field(default_factory=list)
|
||||
iframe_config: Optional[MiniAppPaymentIframeConfig] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PartnerReferrerItem(BaseModel):
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
referral_code: Optional[str] = None
|
||||
referral_commission_percent: Optional[int] = None
|
||||
effective_referral_commission_percent: int
|
||||
invited_count: int
|
||||
active_referrals: int
|
||||
total_earned_kopeks: int
|
||||
total_earned_rubles: float
|
||||
month_earned_kopeks: int
|
||||
month_earned_rubles: float
|
||||
created_at: datetime
|
||||
last_activity: Optional[datetime] = None
|
||||
|
||||
|
||||
class PartnerReferrerListResponse(BaseModel):
|
||||
items: List[PartnerReferrerItem] = Field(default_factory=list)
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PartnerReferralItem(BaseModel):
|
||||
id: int
|
||||
telegram_id: int
|
||||
full_name: str
|
||||
username: Optional[str] = None
|
||||
created_at: datetime
|
||||
last_activity: Optional[datetime] = None
|
||||
has_made_first_topup: bool
|
||||
balance_kopeks: int
|
||||
balance_rubles: float
|
||||
total_earned_kopeks: int
|
||||
total_earned_rubles: float
|
||||
topups_count: int
|
||||
days_since_registration: int
|
||||
days_since_activity: Optional[int] = None
|
||||
status: str
|
||||
|
||||
|
||||
class PartnerReferralList(BaseModel):
|
||||
items: List[PartnerReferralItem] = Field(default_factory=list)
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
has_next: bool
|
||||
has_prev: bool
|
||||
current_page: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class PartnerReferrerDetail(BaseModel):
|
||||
referrer: PartnerReferrerItem
|
||||
referrals: PartnerReferralList
|
||||
|
||||
|
||||
class PartnerReferralCommissionUpdate(BaseModel):
|
||||
referral_commission_percent: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=100,
|
||||
description="Индивидуальный процент реферальной комиссии для пользователя",
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
class PromoOfferUserInfo(BaseModel):
|
||||
@@ -50,7 +50,8 @@ class PromoOfferListResponse(BaseModel):
|
||||
|
||||
|
||||
class PromoOfferCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
user_id: Optional[int] = Field(None, ge=1)
|
||||
telegram_id: Optional[int] = Field(None, ge=1)
|
||||
notification_type: str = Field(..., min_length=1)
|
||||
valid_hours: int = Field(..., ge=1, description="Срок действия предложения в часах")
|
||||
discount_percent: int = Field(0, ge=0)
|
||||
@@ -60,6 +61,65 @@ class PromoOfferCreateRequest(BaseModel):
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PromoOfferBroadcastRequest(PromoOfferCreateRequest):
|
||||
target: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Категория пользователей для рассылки. Поддерживает те же сегменты, что "
|
||||
"и API рассылок (all, active, trial, custom_today и т.д.)."
|
||||
),
|
||||
)
|
||||
|
||||
_ALLOWED_TARGETS: ClassVar[set[str]] = {
|
||||
"all",
|
||||
"active",
|
||||
"trial",
|
||||
"no",
|
||||
"expiring",
|
||||
"expired",
|
||||
"active_zero",
|
||||
"trial_zero",
|
||||
"zero",
|
||||
}
|
||||
_CUSTOM_TARGETS: ClassVar[set[str]] = {
|
||||
"today",
|
||||
"week",
|
||||
"month",
|
||||
"active_today",
|
||||
"inactive_week",
|
||||
"inactive_month",
|
||||
"referrals",
|
||||
"direct",
|
||||
}
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
"no_sub": "no",
|
||||
}
|
||||
|
||||
@validator("target")
|
||||
def validate_target(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
normalized = value.strip().lower()
|
||||
normalized = cls._TARGET_ALIASES.get(normalized, normalized)
|
||||
|
||||
if normalized in cls._ALLOWED_TARGETS:
|
||||
return normalized
|
||||
|
||||
if normalized.startswith("custom_"):
|
||||
criteria = normalized[len("custom_"):]
|
||||
if criteria in cls._CUSTOM_TARGETS:
|
||||
return normalized
|
||||
|
||||
raise ValueError("Unsupported target value")
|
||||
|
||||
|
||||
class PromoOfferBroadcastResponse(BaseModel):
|
||||
created_offers: int
|
||||
user_ids: List[int]
|
||||
target: Optional[str] = None
|
||||
|
||||
|
||||
class PromoOfferTemplateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -32,6 +32,19 @@ class RemnaWaveNode(BaseModel):
|
||||
users_online: Optional[int] = None
|
||||
traffic_used_bytes: Optional[int] = None
|
||||
traffic_limit_bytes: Optional[int] = None
|
||||
last_status_change: Optional[datetime] = None
|
||||
last_status_message: Optional[str] = None
|
||||
xray_uptime: Optional[str] = None
|
||||
is_traffic_tracking_active: bool = False
|
||||
traffic_reset_day: Optional[int] = None
|
||||
notify_percent: Optional[int] = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: Optional[int] = None
|
||||
cpu_model: Optional[str] = None
|
||||
total_ram: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
provider_uuid: Optional[str] = None
|
||||
|
||||
|
||||
class RemnaWaveNodeListResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class SubscriptionEventCreate(BaseModel):
|
||||
event_type: Literal[
|
||||
"activation",
|
||||
"purchase",
|
||||
"renewal",
|
||||
"balance_topup",
|
||||
"promocode_activation",
|
||||
"referral_link_visit",
|
||||
"promo_group_change",
|
||||
]
|
||||
user_id: int = Field(..., ge=1)
|
||||
subscription_id: Optional[int] = Field(default=None, ge=1)
|
||||
transaction_id: Optional[int] = Field(default=None, ge=1)
|
||||
amount_kopeks: Optional[int] = Field(default=None, ge=0)
|
||||
currency: Optional[str] = Field(default=None, min_length=1, max_length=16)
|
||||
message: Optional[str] = Field(default=None, max_length=2000)
|
||||
occurred_at: Optional[datetime] = None
|
||||
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("message")
|
||||
@classmethod
|
||||
def _strip_message(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
class SubscriptionEventResponse(BaseModel):
|
||||
id: int
|
||||
event_type: str
|
||||
user_id: int
|
||||
user_full_name: str
|
||||
user_username: Optional[str] = None
|
||||
user_telegram_id: int
|
||||
subscription_id: Optional[int] = None
|
||||
transaction_id: Optional[int] = None
|
||||
amount_kopeks: Optional[int] = None
|
||||
currency: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
occurred_at: datetime
|
||||
created_at: datetime
|
||||
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SubscriptionEventListResponse(BaseModel):
|
||||
items: list[SubscriptionEventResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -67,3 +67,4 @@ class TicketMediaResponse(BaseModel):
|
||||
media_type: str
|
||||
media_file_id: str
|
||||
media_caption: Optional[str] = None
|
||||
media_url: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
def _normalize_text(value: str) -> str:
|
||||
cleaned = (value or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Message text cannot be empty")
|
||||
return cleaned
|
||||
|
||||
|
||||
class UserMessageResponse(BaseModel):
|
||||
id: int
|
||||
message_text: str
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
created_by: Optional[int]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserMessageCreateRequest(BaseModel):
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
is_active: bool = True
|
||||
sort_order: int = Field(0, ge=0)
|
||||
|
||||
_normalize_message_text = validator("message_text", allow_reuse=True)(_normalize_text)
|
||||
|
||||
|
||||
class UserMessageUpdateRequest(BaseModel):
|
||||
message_text: Optional[str] = Field(None, min_length=1, max_length=4000)
|
||||
is_active: Optional[bool] = None
|
||||
sort_order: Optional[int] = Field(None, ge=0)
|
||||
|
||||
@validator("message_text")
|
||||
def validate_message_text(cls, value): # noqa: D401,B902
|
||||
if value is None:
|
||||
return value
|
||||
return _normalize_text(value)
|
||||
|
||||
|
||||
class UserMessageListResponse(BaseModel):
|
||||
items: list[UserMessageResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
def _normalize_text(value: str) -> str:
|
||||
cleaned = (value or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Text cannot be empty")
|
||||
return cleaned
|
||||
|
||||
|
||||
class WelcomeTextResponse(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
is_active: bool
|
||||
is_enabled: bool
|
||||
created_by: Optional[int]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WelcomeTextCreateRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=4000)
|
||||
is_enabled: bool = True
|
||||
is_active: bool = True
|
||||
|
||||
_normalize_text = validator("text", allow_reuse=True)(_normalize_text)
|
||||
|
||||
|
||||
class WelcomeTextUpdateRequest(BaseModel):
|
||||
text: Optional[str] = Field(None, min_length=1, max_length=4000)
|
||||
is_enabled: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@validator("text")
|
||||
def validate_text(cls, value): # noqa: D401,B902
|
||||
if value is None:
|
||||
return value
|
||||
return _normalize_text(value)
|
||||
|
||||
|
||||
class WelcomeTextListResponse(BaseModel):
|
||||
items: list[WelcomeTextResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -13,6 +13,7 @@ from aiogram import Dispatcher
|
||||
from app.config import settings
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.webapi.app import create_web_api_app
|
||||
from app.webapi.docs import add_redoc_endpoint
|
||||
|
||||
from . import payments
|
||||
from . import telegram
|
||||
@@ -47,12 +48,19 @@ def _create_base_app() -> FastAPI:
|
||||
app = create_web_api_app()
|
||||
else:
|
||||
app = FastAPI(
|
||||
title="Bedolaga Unified Server",
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
)
|
||||
title="Bedolaga Unified Server",
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=None,
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
)
|
||||
|
||||
add_redoc_endpoint(
|
||||
app,
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
title="Bedolaga Unified Server",
|
||||
)
|
||||
|
||||
_attach_docs_alias(app, app.docs_url)
|
||||
return app
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- bot_network
|
||||
- remnawave-network # Подключаем к сети панели
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
|
||||
interval: 30s
|
||||
@@ -28,6 +29,7 @@ services:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- bot_network
|
||||
- remnawave-network # Подключаем к сети панели
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
@@ -53,28 +55,21 @@ services:
|
||||
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
|
||||
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
|
||||
TZ: "Europe/Moscow"
|
||||
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
|
||||
volumes:
|
||||
# Логи
|
||||
- ./logs:/app/logs:rw
|
||||
# Данные приложения (для SQLite в случае переключения)
|
||||
- ./data:/app/data:rw
|
||||
- ./locales:/app/locales:rw
|
||||
# Конфигурация приложения
|
||||
# - ./app-config.json:/app/app-config.json:ro
|
||||
# Timezone
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
# Логотип для сообщений
|
||||
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||
ports:
|
||||
- "${WEB_API_PORT:-8080}:8080"
|
||||
networks:
|
||||
- bot_network
|
||||
- remnawave-network # Подключаем к сети панели
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
|
||||
interval: 60s
|
||||
@@ -95,3 +90,7 @@ networks:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
|
||||
remnawave-network:
|
||||
name: remnawave-network
|
||||
external: true # Используем существующую сеть панели
|
||||
|
||||
@@ -128,7 +128,7 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
|
||||
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
|
||||
| `GET` | `/promo-offers` | Список промо-предложений с фильтрами по пользователю, статусу и типу уведомления.
|
||||
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю.
|
||||
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю. ID может быть как внутренним (user.id), так и Telegram ID (user.telegram_id).
|
||||
| `GET` | `/promo-offers/{id}` | Детали конкретного промо-предложения.
|
||||
| `GET` | `/promo-offers/templates` | Список шаблонов промо-предложений.
|
||||
| `GET` | `/promo-offers/templates/{id}` | Получить данные шаблона промо-предложения.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Add subscription_events table"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
revision: str = "c2f9c3b5f5c4"
|
||||
down_revision: Union[str, None] = "9f0f2d5a1c7b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
TABLE_NAME = "subscription_events"
|
||||
|
||||
|
||||
def _table_exists(inspector: Inspector) -> bool:
|
||||
return TABLE_NAME in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("event_type", sa.String(length=50), nullable=False),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"subscription_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("subscriptions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"transaction_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("transactions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("amount_kopeks", sa.Integer(), nullable=True),
|
||||
sa.Column("currency", sa.String(length=16), nullable=True),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"occurred_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("extra", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"ix_subscription_events_event_type", TABLE_NAME, ["event_type"]
|
||||
)
|
||||
op.create_index("ix_subscription_events_user_id", TABLE_NAME, ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector):
|
||||
return
|
||||
|
||||
op.drop_index("ix_subscription_events_user_id", table_name=TABLE_NAME)
|
||||
op.drop_index("ix_subscription_events_event_type", table_name=TABLE_NAME)
|
||||
op.drop_table(TABLE_NAME)
|
||||
@@ -0,0 +1,19 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e3c1e0b5b4a7"
|
||||
down_revision: Union[str, None] = "c2f9c3b5f5c4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("referral_commission_percent", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "referral_commission_percent")
|
||||
+363
-42
@@ -40,6 +40,7 @@
|
||||
--success: #10b981;
|
||||
--success-rgb: 16, 185, 129;
|
||||
--warning: #f59e0b;
|
||||
--warning-rgb: 245, 158, 11;
|
||||
--danger: #ef4444;
|
||||
--danger-rgb: 239, 68, 68;
|
||||
--info: #3b82f6;
|
||||
@@ -288,6 +289,80 @@
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.maintenance-banner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(var(--warning-rgb), 0.18);
|
||||
background: linear-gradient(135deg, rgba(var(--warning-rgb), 0.08), rgba(var(--warning-rgb), 0.02));
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.maintenance-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.maintenance-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.maintenance-title {
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.maintenance-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.maintenance-state {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.maintenance-state .maintenance-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
font-size: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
border-radius: 50%;
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.maintenance-state .maintenance-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.maintenance-state .maintenance-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -4766,6 +4841,24 @@
|
||||
<div class="subtitle" data-i18n="app.subtitle">Secure & Fast Connection</div>
|
||||
</div>
|
||||
|
||||
<div id="maintenanceBanner" class="maintenance-banner hidden" role="status" aria-live="polite">
|
||||
<div class="maintenance-icon" aria-hidden="true">🔧</div>
|
||||
<div class="maintenance-content">
|
||||
<div class="maintenance-title" data-i18n="maintenance.title">Технические работы</div>
|
||||
<div id="maintenanceMessage" class="maintenance-text" data-i18n="maintenance.message">
|
||||
Сервис временно недоступен из-за технических работ. Попробуйте позже.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="maintenanceState" class="maintenance-state hidden" role="status" aria-live="polite">
|
||||
<div class="maintenance-icon" aria-hidden="true">🔧</div>
|
||||
<div class="maintenance-title" data-i18n="maintenance.title">Технические работы</div>
|
||||
<div id="maintenanceStateMessage" class="maintenance-text" data-i18n="maintenance.message">
|
||||
Сервис временно недоступен из-за технических работ. Попробуйте позже.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="loadingState" class="loading">
|
||||
<div class="spinner"></div>
|
||||
@@ -5586,6 +5679,8 @@
|
||||
'values.not_available': 'Not available',
|
||||
'app.subtitle': 'Secure & Fast Connection',
|
||||
'app.loading': 'Loading your subscription...',
|
||||
'maintenance.title': 'Technical maintenance',
|
||||
'maintenance.message': 'The service is temporarily in maintenance mode. Some actions may be unavailable.',
|
||||
'error.default.title': 'Subscription Not Found',
|
||||
'error.default.message': 'Please contact support to activate your subscription.',
|
||||
'error.user_not_found.title': 'Register in the bot',
|
||||
@@ -5615,6 +5710,18 @@
|
||||
'topup.method.yookassa.description': 'Pay securely with a bank card',
|
||||
'topup.method.mulenpay.title': 'Bank card (Mulen Pay)',
|
||||
'topup.method.mulenpay.description': 'Fast payment with bank card',
|
||||
'topup.method.platega.title': 'Platega.io',
|
||||
'topup.method.platega.description': 'Bank cards and SBP via Platega',
|
||||
'topup.method.platega.option.2.title': 'SBP (QR)',
|
||||
'topup.method.platega.option.2.description': 'Pay with Faster Payments QR code.',
|
||||
'topup.method.platega.option.10.title': 'Bank cards (RUB)',
|
||||
'topup.method.platega.option.10.description': 'Russian bank cards through Platega.',
|
||||
'topup.method.platega.option.11.title': 'Bank cards',
|
||||
'topup.method.platega.option.11.description': 'Local bank cards via Platega.',
|
||||
'topup.method.platega.option.12.title': 'International cards',
|
||||
'topup.method.platega.option.12.description': 'International cards supported by Platega.',
|
||||
'topup.method.platega.option.13.title': 'Cryptocurrency',
|
||||
'topup.method.platega.option.13.description': 'Top up balance with crypto via Platega.',
|
||||
'topup.method.wata.title': 'Bank card (Wata)',
|
||||
'topup.method.wata.description': 'Pay with a bank card via Wata',
|
||||
'topup.method.pal24.title': 'SBP (PayPalych)',
|
||||
@@ -5992,6 +6099,8 @@
|
||||
'values.not_available': 'Закрыто',
|
||||
'app.subtitle': 'Безопасное и быстрое подключение',
|
||||
'app.loading': 'Загружаем вашу подписку...',
|
||||
'maintenance.title': 'Технические работы',
|
||||
'maintenance.message': 'Сервис находится в режиме технических работ. Некоторые действия могут быть недоступны.',
|
||||
'error.default.title': 'Подписка не найдена',
|
||||
'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.',
|
||||
'error.user_not_found.title': 'Зарегистрируйтесь в боте',
|
||||
@@ -6021,6 +6130,18 @@
|
||||
'topup.method.yookassa.description': 'Безопасная оплата банковской картой',
|
||||
'topup.method.mulenpay.title': 'Банковская карта (Mulen Pay)',
|
||||
'topup.method.mulenpay.description': 'Мгновенное списание с карты',
|
||||
'topup.method.platega.title': 'Platega.io',
|
||||
'topup.method.platega.description': 'Карта или СБП через Platega',
|
||||
'topup.method.platega.option.2.title': 'СБП (QR)',
|
||||
'topup.method.platega.option.2.description': 'Оплата по QR-коду через СБП.',
|
||||
'topup.method.platega.option.10.title': 'Банковские карты (RUB)',
|
||||
'topup.method.platega.option.10.description': 'Российские карты через Platega.',
|
||||
'topup.method.platega.option.11.title': 'Банковские карты',
|
||||
'topup.method.platega.option.11.description': 'Оплата картами через Platega.',
|
||||
'topup.method.platega.option.12.title': 'Международные карты',
|
||||
'topup.method.platega.option.12.description': 'Оплата международными картами.',
|
||||
'topup.method.platega.option.13.title': 'Криптовалюта',
|
||||
'topup.method.platega.option.13.description': 'Пополнение через криптовалюту в Platega.',
|
||||
'topup.method.wata.title': 'Банковская карта (Wata)',
|
||||
'topup.method.wata.description': 'Оплата банковской картой через Wata',
|
||||
'topup.method.pal24.title': 'СБП (PayPalych)',
|
||||
@@ -6664,6 +6785,7 @@
|
||||
let paymentMethodsCache = null;
|
||||
let paymentMethodsPromise = null;
|
||||
let activePaymentMethod = null;
|
||||
let maintenanceState = { isActive: false, message: null };
|
||||
const paymentMethodSelections = {};
|
||||
const activePaymentMonitors = new Map();
|
||||
let paymentStatusPollTimer = null;
|
||||
@@ -6841,13 +6963,18 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.method.id === 'pal24') {
|
||||
const option = (monitor.option || 'sbp').toLowerCase();
|
||||
const optionKey = option === 'card' ? 'card' : 'sbp';
|
||||
const fallback = optionKey === 'card'
|
||||
? 'Bank card payment'
|
||||
: 'Faster Payments (SBP)';
|
||||
setTopupModalSubtitle(`topup.method.pal24.option.${optionKey}.title`, fallback);
|
||||
const optionsMap = (Array.isArray(monitor.method.options) ? monitor.method.options : []).reduce((map, item) => {
|
||||
map[String(item.id)] = item;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
if (monitor.method.id === 'pal24' || monitor.method.id === 'platega') {
|
||||
const option = (monitor.option || monitor.extra?.selected_option || 'sbp').toString();
|
||||
const optionKey = ['card', 'sbp'].includes(option) ? option : option;
|
||||
const optionConfig = optionsMap[optionKey];
|
||||
const fallback = optionConfig?.title || (optionKey === 'card' ? 'Bank card payment' : 'Faster Payments (SBP)');
|
||||
const titleKey = optionConfig?.titleKey || optionConfig?.title_key || `topup.method.${monitor.method.id}.option.${optionKey}.title`;
|
||||
setTopupModalSubtitle(titleKey, fallback || monitor.method.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6878,6 +7005,11 @@
|
||||
identifiers.paymentId = extra.payment_id;
|
||||
}
|
||||
|
||||
if (extra.correlation_id && !query.payload) {
|
||||
query.payload = extra.correlation_id;
|
||||
identifiers.payload = extra.correlation_id;
|
||||
}
|
||||
|
||||
const payloadValue = extra.payload || extra.invoice_payload;
|
||||
if (payloadValue) {
|
||||
query.payload = payloadValue;
|
||||
@@ -7462,6 +7594,80 @@
|
||||
label.textContent = t(key);
|
||||
}
|
||||
|
||||
function resolveMaintenanceMessage() {
|
||||
const resolvedMessage = (typeof maintenanceState.message === 'string'
|
||||
? maintenanceState.message.trim()
|
||||
: '')
|
||||
|| t('maintenance.message');
|
||||
const translatedFallback = t('maintenance.message');
|
||||
const messageFallback = translatedFallback === 'maintenance.message'
|
||||
? 'The service is temporarily unavailable due to maintenance. Please try again later.'
|
||||
: translatedFallback;
|
||||
|
||||
return resolvedMessage === 'maintenance.message'
|
||||
? messageFallback
|
||||
: resolvedMessage;
|
||||
}
|
||||
|
||||
function renderMaintenanceBanner() {
|
||||
const banner = document.getElementById('maintenanceBanner');
|
||||
const messageElement = document.getElementById('maintenanceMessage');
|
||||
|
||||
if (!banner || !messageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!maintenanceState?.isActive) {
|
||||
banner.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
messageElement.textContent = resolveMaintenanceMessage();
|
||||
banner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function renderMaintenanceScreen() {
|
||||
const screen = document.getElementById('maintenanceState');
|
||||
const messageElement = document.getElementById('maintenanceStateMessage');
|
||||
const banner = document.getElementById('maintenanceBanner');
|
||||
const loadingState = document.getElementById('loadingState');
|
||||
const errorState = document.getElementById('errorState');
|
||||
const mainContent = document.getElementById('mainContent');
|
||||
|
||||
if (!screen || !messageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!maintenanceState?.isActive) {
|
||||
screen.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
messageElement.textContent = resolveMaintenanceMessage();
|
||||
screen.classList.remove('hidden');
|
||||
|
||||
if (banner) {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
if (loadingState) {
|
||||
loadingState.classList.add('hidden');
|
||||
}
|
||||
if (errorState) {
|
||||
errorState.classList.add('hidden');
|
||||
}
|
||||
if (mainContent) {
|
||||
mainContent.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function applyMaintenanceStatus(status) {
|
||||
const isActive = Boolean(status?.isActive ?? status?.is_active);
|
||||
const message = typeof status?.message === 'string' ? status.message : null;
|
||||
maintenanceState = { isActive, message };
|
||||
renderMaintenanceBanner();
|
||||
renderMaintenanceScreen();
|
||||
}
|
||||
|
||||
function refreshAfterLanguageChange() {
|
||||
applyTranslations();
|
||||
if (userData) {
|
||||
@@ -7471,6 +7677,7 @@
|
||||
}
|
||||
renderApps();
|
||||
updateActionButtons();
|
||||
renderMaintenanceBanner();
|
||||
}
|
||||
|
||||
function setLanguage(language, options = {}) {
|
||||
@@ -7669,6 +7876,22 @@
|
||||
hasAnimatedCards = true;
|
||||
}
|
||||
|
||||
async function fetchMaintenanceStatus(initData) {
|
||||
const response = await fetch('/miniapp/maintenance/status', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ initData })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
throw new Error('Unable to fetch maintenance status');
|
||||
}
|
||||
|
||||
async function fetchSubscriptionPayload(initData) {
|
||||
const response = await fetch('/miniapp/subscription', {
|
||||
method: 'POST',
|
||||
@@ -7910,6 +8133,23 @@
|
||||
return applySubscriptionData(payload);
|
||||
}
|
||||
|
||||
async function checkMaintenance(initData) {
|
||||
if (!initData) {
|
||||
applyMaintenanceStatus({ isActive: false, message: null });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await fetchMaintenanceStatus(initData);
|
||||
applyMaintenanceStatus(status);
|
||||
return Boolean(status?.isActive ?? status?.is_active);
|
||||
} catch (error) {
|
||||
console.warn('Unable to load maintenance status:', error);
|
||||
applyMaintenanceStatus({ isActive: false, message: null });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const telegramUser = tg.initDataUnsafe?.user;
|
||||
@@ -7923,6 +8163,11 @@
|
||||
}
|
||||
|
||||
await loadAppsConfig();
|
||||
const initData = tg.initData || '';
|
||||
const maintenanceActive = await checkMaintenance(initData);
|
||||
if (maintenanceActive) {
|
||||
return;
|
||||
}
|
||||
await refreshSubscriptionData();
|
||||
} catch (error) {
|
||||
console.error('Initialization error:', error);
|
||||
@@ -7932,12 +8177,13 @@
|
||||
|
||||
async function loadAppsConfig() {
|
||||
try {
|
||||
const response = await fetch('/app-config.json', { cache: 'no-cache' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load app config');
|
||||
const fallbackPaths = getAppConfigCandidatePaths();
|
||||
const data = await fetchFirstAvailableAppConfig(fallbackPaths);
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No available app config sources');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
appsConfig = sanitizeAppsConfig(data?.platforms || {});
|
||||
ensurePlatformFilter();
|
||||
|
||||
@@ -8001,9 +8247,53 @@
|
||||
} catch (error) {
|
||||
console.warn('Unable to load apps configuration:', error);
|
||||
appsConfig = {};
|
||||
ensurePlatformFilter();
|
||||
}
|
||||
}
|
||||
|
||||
function getAppConfigCandidatePaths() {
|
||||
const paths = [];
|
||||
const currentPath = window.location?.pathname || '/';
|
||||
const basePath = currentPath.endsWith('/')
|
||||
? currentPath
|
||||
: currentPath.replace(/[^/]*$/, '');
|
||||
|
||||
const normalizedBase = basePath || '/';
|
||||
const relativePath = `${normalizedBase}app-config.json`;
|
||||
paths.push(relativePath);
|
||||
|
||||
['/miniapp/app-config.json', '/app-config.json'].forEach(path => {
|
||||
if (!paths.includes(path)) {
|
||||
paths.push(path);
|
||||
}
|
||||
});
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function fetchFirstAvailableAppConfig(paths) {
|
||||
if (!Array.isArray(paths) || !paths.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const response = await fetch(path, { cache: 'no-cache' });
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data && typeof data === 'object') {
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('App config path failed:', path, error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderUserData() {
|
||||
if (!userData?.user) {
|
||||
return;
|
||||
@@ -11503,32 +11793,44 @@
|
||||
form.appendChild(hint);
|
||||
}
|
||||
|
||||
if (method.id === 'pal24') {
|
||||
const optionsConfig = [
|
||||
const providedOptions = Array.isArray(method.options) ? method.options : [];
|
||||
const fallbackOptions = method.id === 'pal24'
|
||||
? [
|
||||
{
|
||||
id: 'sbp',
|
||||
icon: '🏦',
|
||||
titleKey: 'topup.method.pal24.option.sbp.title',
|
||||
descriptionKey: 'topup.method.pal24.option.sbp.description',
|
||||
fallbackTitle: 'Faster Payments (SBP)',
|
||||
fallbackDescription: 'Instant SBP transfer with no fees.',
|
||||
title: 'Faster Payments (SBP)',
|
||||
description: 'Instant SBP transfer with no fees.',
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
icon: '💳',
|
||||
titleKey: 'topup.method.pal24.option.card.title',
|
||||
descriptionKey: 'topup.method.pal24.option.card.description',
|
||||
fallbackTitle: 'Bank card',
|
||||
fallbackDescription: 'Pay with a bank card via PayPalych.',
|
||||
title: 'Bank card',
|
||||
description: 'Pay with a bank card via PayPalych.',
|
||||
},
|
||||
];
|
||||
]
|
||||
: [];
|
||||
|
||||
const selectedDefault = options.selectedOption
|
||||
const optionsConfig = (providedOptions.length ? providedOptions : fallbackOptions).map(option => ({
|
||||
id: String(option.id),
|
||||
icon: option.icon || '💳',
|
||||
titleKey: option.titleKey || option.title_key || option.titlekey,
|
||||
descriptionKey: option.descriptionKey || option.description_key || option.descriptionkey,
|
||||
fallbackTitle: option.title || option.name || String(option.id),
|
||||
fallbackDescription: option.description || '',
|
||||
}));
|
||||
|
||||
if (optionsConfig.length) {
|
||||
const defaultSelection = options.selectedOption
|
||||
|| paymentMethodSelections[method.id]
|
||||
|| 'sbp';
|
||||
let currentOption = optionsConfig.some(option => option.id === selectedDefault)
|
||||
? selectedDefault
|
||||
: 'sbp';
|
||||
|| optionsConfig[0]?.id;
|
||||
let currentOption = optionsConfig.some(option => option.id === defaultSelection)
|
||||
? defaultSelection
|
||||
: optionsConfig[0]?.id;
|
||||
paymentMethodSelections[method.id] = currentOption;
|
||||
form.dataset.paymentOption = currentOption;
|
||||
|
||||
@@ -11537,7 +11839,7 @@
|
||||
|
||||
const optionTitle = document.createElement('div');
|
||||
optionTitle.className = 'payment-option-title';
|
||||
const titleKey = 'topup.method.pal24.title';
|
||||
const titleKey = `topup.method.${method.id}.title`;
|
||||
const titleValue = t(titleKey);
|
||||
optionTitle.textContent = titleValue === titleKey ? 'Choose payment type' : titleValue;
|
||||
optionGroup.appendChild(optionTitle);
|
||||
@@ -11563,22 +11865,23 @@
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'payment-option-label';
|
||||
const labelValue = t(config.titleKey);
|
||||
label.textContent = labelValue === config.titleKey ? config.fallbackTitle : labelValue;
|
||||
const labelValue = config.titleKey ? t(config.titleKey) : config.fallbackTitle;
|
||||
label.textContent = labelValue && labelValue !== config.titleKey
|
||||
? labelValue
|
||||
: config.fallbackTitle;
|
||||
|
||||
const description = document.createElement('div');
|
||||
description.className = 'payment-option-description';
|
||||
const descriptionValue = t(config.descriptionKey);
|
||||
const finalDescription = descriptionValue === config.descriptionKey
|
||||
? config.fallbackDescription
|
||||
: descriptionValue;
|
||||
description.textContent = finalDescription;
|
||||
|
||||
text.appendChild(label);
|
||||
const descriptionValue = config.descriptionKey ? t(config.descriptionKey) : config.fallbackDescription;
|
||||
const finalDescription = descriptionValue && descriptionValue !== config.descriptionKey
|
||||
? descriptionValue
|
||||
: config.fallbackDescription;
|
||||
if (finalDescription) {
|
||||
description.textContent = finalDescription;
|
||||
text.appendChild(description);
|
||||
}
|
||||
|
||||
text.appendChild(label);
|
||||
button.appendChild(icon);
|
||||
button.appendChild(text);
|
||||
|
||||
@@ -11816,8 +12119,19 @@
|
||||
const normalizedAmount = Number.isFinite(amountKopeks) ? Number(amountKopeks) : null;
|
||||
const monitorExtra = { ...extra };
|
||||
|
||||
const methodOptions = Array.isArray(method.options) ? method.options : [];
|
||||
const optionsMap = methodOptions.reduce((map, item) => {
|
||||
const key = String(item.id);
|
||||
map[key] = item;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
let option = null;
|
||||
if (method.id === 'pal24') {
|
||||
if (methodOptions.length) {
|
||||
option = (options.providerOption || monitorExtra.selected_option || paymentMethodSelections[method.id] || methodOptions[0]?.id || '').toString();
|
||||
paymentMethodSelections[method.id] = option;
|
||||
monitorExtra.selected_option = option;
|
||||
} else if (method.id === 'pal24') {
|
||||
option = (options.providerOption || monitorExtra.selected_option || paymentMethodSelections[method.id] || 'sbp').toLowerCase();
|
||||
if (!['card', 'sbp'].includes(option)) {
|
||||
option = 'sbp';
|
||||
@@ -11826,12 +12140,19 @@
|
||||
monitorExtra.selected_option = option;
|
||||
}
|
||||
|
||||
const titleKey = method.id === 'pal24' && option
|
||||
? `topup.method.pal24.option.${option}.title`
|
||||
: `topup.method.${method.id}.title`;
|
||||
const titleFallback = method.id === 'pal24'
|
||||
? (option === 'card' ? 'Bank card payment' : 'Faster Payments (SBP)')
|
||||
: method.id;
|
||||
const selectedOption = option && (optionsMap[option] || optionsMap[String(option)]);
|
||||
const optionTitleKey = selectedOption?.titleKey || selectedOption?.title_key;
|
||||
const optionTitleFallback = selectedOption?.title || selectedOption?.name || option || method.id;
|
||||
const titleKey = selectedOption && optionTitleKey
|
||||
? optionTitleKey
|
||||
: option
|
||||
? `topup.method.${method.id}.option.${option}.title`
|
||||
: `topup.method.${method.id}.title`;
|
||||
const titleFallback = selectedOption
|
||||
? optionTitleFallback
|
||||
: method.id === 'pal24'
|
||||
? (option === 'card' ? 'Bank card payment' : 'Faster Payments (SBP)')
|
||||
: method.id;
|
||||
setTopupModalSubtitle(titleKey, titleFallback);
|
||||
|
||||
body.innerHTML = '';
|
||||
@@ -11902,8 +12223,8 @@
|
||||
summary.appendChild(usdAmount);
|
||||
}
|
||||
|
||||
const descriptionKey = method.id === 'pal24' && option
|
||||
? `topup.method.pal24.option.${option}.description`
|
||||
const descriptionKey = option
|
||||
? `topup.method.${method.id}.option.${option}.description`
|
||||
: `topup.method.${method.id}.description`;
|
||||
const descriptionValue = t(descriptionKey);
|
||||
if (descriptionValue && descriptionValue !== descriptionKey) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PromoGroup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_discount_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "BASE_PROMO_GROUP_PERIOD_DISCOUNTS", "60:15")
|
||||
yield
|
||||
|
||||
|
||||
def test_base_promo_discount_applies_to_all_categories(base_discount_settings):
|
||||
promo_group = PromoGroup(name="Default", is_default=True)
|
||||
|
||||
assert promo_group.get_discount_percent("period", 60) == 15
|
||||
assert promo_group.get_discount_percent("servers", 60) == 15
|
||||
assert promo_group.get_discount_percent("traffic", 60) == 15
|
||||
assert promo_group.get_discount_percent("devices", 60) == 15
|
||||
|
||||
|
||||
def test_specific_category_discount_overrides_base(base_discount_settings):
|
||||
promo_group = PromoGroup(
|
||||
name="Default", is_default=True, server_discount_percent=5
|
||||
)
|
||||
|
||||
assert promo_group.get_discount_percent("servers", 60) == 5
|
||||
assert promo_group.get_discount_percent("devices", 60) == 15
|
||||
@@ -133,7 +133,6 @@ async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPat
|
||||
app = _build_unified_app(monkeypatch, docs_enabled=True)
|
||||
|
||||
assert app.docs_url == "/docs"
|
||||
assert app.redoc_url == "/redoc"
|
||||
assert app.openapi_url == "/openapi.json"
|
||||
|
||||
alias_route = next(
|
||||
@@ -143,6 +142,16 @@ async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPat
|
||||
assert alias_route is not None
|
||||
assert getattr(alias_route, "include_in_schema", True) is False
|
||||
|
||||
redoc_route = next(
|
||||
(route for route in app.routes if getattr(route, "path", None) == "/redoc"),
|
||||
None,
|
||||
)
|
||||
assert redoc_route is not None
|
||||
assert getattr(redoc_route, "include_in_schema", True) is False
|
||||
|
||||
response = await alias_route.endpoint() # type: ignore[func-returns-value]
|
||||
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
|
||||
assert response.headers["location"] == "/docs"
|
||||
|
||||
redoc_response = await redoc_route.endpoint() # type: ignore[func-returns-value]
|
||||
assert b"ReDoc" in redoc_response.body # type: ignore[attr-defined]
|
||||
|
||||
Reference in New Issue
Block a user