Merge pull request #2917 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-04-29 12:11:47 +03:00
committed by GitHub
49 changed files with 684 additions and 352 deletions
-6
View File
@@ -1029,10 +1029,4 @@ WEB_API_TOKEN_HASH_ALGORITHM=sha256
# Логирование запросов
WEB_API_REQUEST_LOGGING=true
# Внешний админ-токен (для интеграции с другими ботами/системами)
# Токен для доступа через API другого бота
# EXTERNAL_ADMIN_TOKEN=
# ID бота, от которого принимается токен
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
MINIAPP_STATIC_PATH=miniapp
+2 -2
View File
@@ -33,8 +33,8 @@ WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \
chown -R app:app logs data uploads
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails locales && \
chown -R app:app logs data uploads locales
USER app
+11
View File
@@ -1,5 +1,7 @@
"""FastAPI dependencies for cabinet module."""
from datetime import UTC, datetime
import structlog
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -176,6 +178,15 @@ async def get_current_cabinet_user(
},
)
# Throttled update of cabinet_last_login (at most every 5 minutes)
now = datetime.now(UTC)
if not user.cabinet_last_login or (now - user.cabinet_last_login).total_seconds() > 300:
try:
user.cabinet_last_login = now
await db.commit()
except Exception:
pass
return user
+11 -1
View File
@@ -502,6 +502,16 @@ async def _do_delete_subscription(
tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}'
# Protect active paid subscriptions from accidental deletion
if sub.is_active and not sub.is_trial and not params.force_delete_active_paid:
return BulkUserResult(
user_id=user.id,
success=False,
message=f'Skipped: {tariff_name} is active and paid (enable force_delete_active_paid to override)',
username=user.username,
subscriptions=_build_subscription_info(getattr(user, 'subscriptions', None) or []),
)
if dry_run:
return BulkUserResult(
user_id=user.id,
@@ -890,7 +900,7 @@ async def _execute_for_subscription(
async def bulk_execute(
request: BulkExecuteRequest,
stream: bool = Query(default=False, description='Stream progress via SSE'),
admin: User = Depends(require_permission('users:edit')),
admin: User = Depends(require_permission('bulk_actions:execute')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Execute a bulk action on multiple users or subscriptions.
+7 -7
View File
@@ -34,7 +34,7 @@ router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages']
@router.get('', response_model=list[InfoPageListItem])
async def list_all_info_pages(
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
admin: User = Depends(require_permission('settings:read')),
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all info pages (admin view, includes inactive)."""
@@ -54,7 +54,7 @@ async def list_all_info_pages(
@router.get('/{page_id}', response_model=InfoPageResponse)
async def get_info_page_detail(
page_id: int,
admin: User = Depends(require_permission('settings:read')),
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by ID (admin view)."""
@@ -70,7 +70,7 @@ async def get_info_page_detail(
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
async def create_page(
request: InfoPageCreateRequest,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Create a new info page."""
@@ -108,7 +108,7 @@ async def create_page(
async def update_page(
page_id: int,
request: InfoPageUpdateRequest,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Update an existing info page."""
@@ -150,7 +150,7 @@ async def update_page(
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_page(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete an info page."""
@@ -174,7 +174,7 @@ async def remove_page(
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
async def reorder_pages(
request: ReorderRequest,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Bulk update sort_order for info pages."""
@@ -191,7 +191,7 @@ async def reorder_pages(
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
async def toggle_active(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Toggle the active status of an info page."""
+88 -9
View File
@@ -1,5 +1,6 @@
"""Admin routes for managing users in cabinet."""
import math
from datetime import UTC, datetime, timedelta
import structlog
@@ -157,6 +158,7 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
tariff_id=s.tariff_id,
tariff_name=s.tariff.name if s.tariff else None,
status=s.status,
is_trial=bool(s.is_trial),
end_date=s.end_date,
days_remaining=s_days,
traffic_used_gb=s.traffic_used_gb or 0.0,
@@ -391,7 +393,10 @@ async def _sync_subscription_to_panel(
changes['action'] = 'updated'
logger.info('Updated user in Remnawave panel', user_id=user.id)
except Exception as update_error:
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '')
if (
hasattr(update_error, 'status_code') and update_error.status_code == 404
) or error_code == 'A018':
panel_uuid = None # Will create new
else:
raise
@@ -895,6 +900,50 @@ async def get_user_panel_info(
return UserPanelInfoResponse(found=False)
@router.get('/{user_id}/subscription-request-history')
async def get_subscription_request_history(
user_id: int,
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
"""Get subscription request history from RemnaWave panel."""
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
panel_uuid = None
if settings.is_multi_tariff_enabled() and subscription_id:
from app.database.crud.subscription import get_subscription_by_id_for_user
sub = await get_subscription_by_id_for_user(db, subscription_id, user_id)
if sub:
panel_uuid = sub.remnawave_uuid
else:
panel_uuid = getattr(user, 'remnawave_uuid', None)
if not panel_uuid:
return {'total': 0, 'records': []}
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if not service.is_configured:
return {'total': 0, 'records': []}
async with service.get_api_client() as api:
result = await api.get_subscription_request_history(panel_uuid, offset=offset, limit=limit)
return result
except Exception as e:
logger.error('Error getting subscription request history', user_id=user_id, error=e)
return {'total': 0, 'records': []}
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
async def get_user_node_usage(
user_id: int,
@@ -1743,9 +1792,25 @@ async def block_user(
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Block a user (shortcut for status update)."""
request = UpdateUserStatusRequest(status=UserStatusEnum.BLOCKED, reason=reason)
return await update_user_status(user_id, request, admin, db)
"""Block a user — sets DB status AND disables panel user in RemnaWave."""
from app.services.user_service import UserService
user_service = UserService()
success = await user_service.block_user(
db,
user_id,
admin.id,
reason=reason or 'Заблокирован администратором',
)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or block failed')
return UpdateUserStatusResponse(
success=True,
old_status='active',
new_status='blocked',
message='User blocked',
)
@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse)
@@ -1754,9 +1819,20 @@ async def unblock_user(
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unblock a user (shortcut for status update)."""
request = UpdateUserStatusRequest(status=UserStatusEnum.ACTIVE)
return await update_user_status(user_id, request, admin, db)
"""Unblock a user — sets DB status AND re-enables panel user in RemnaWave."""
from app.services.user_service import UserService
user_service = UserService()
success = await user_service.unblock_user(db, user_id, admin.id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or unblock failed')
return UpdateUserStatusResponse(
success=True,
old_status='blocked',
new_status='active',
message='User unblocked',
)
# === Restrictions Management ===
@@ -3159,7 +3235,7 @@ async def sync_user_from_panel(
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
)
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_utc - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((panel_expire_utc - datetime.now(UTC)).total_seconds() / 86400))
new_sub = await create_paid_subscription(
db=db,
@@ -3362,7 +3438,10 @@ async def sync_user_to_panel(
await api.update_user(**update_kwargs)
action = 'updated'
except Exception as update_error:
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '')
if (
hasattr(update_error, 'status_code') and update_error.status_code == 404
) or error_code == 'A018':
# User not found in panel, create new
panel_uuid = None
else:
+10
View File
@@ -1206,15 +1206,20 @@ async def get_latest_payment_by_method(
from sqlalchemy.orm import selectinload
from app.database.models import (
AuraPayPayment,
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
OverpayPayment,
Pal24Payment,
PayPearPayment,
PlategaPayment,
RioPayPayment,
RollyPayPayment,
SeverPayPayment,
WataPayment,
YooKassaPayment,
)
@@ -1231,6 +1236,11 @@ async def get_latest_payment_by_method(
PaymentMethod.FREEKASSA: FreekassaPayment,
PaymentMethod.KASSA_AI: KassaAiPayment,
PaymentMethod.RIOPAY: RioPayPayment,
PaymentMethod.SEVERPAY: SeverPayPayment,
PaymentMethod.ROLLYPAY: RollyPayPayment,
PaymentMethod.PAYPEAR: PayPearPayment,
PaymentMethod.OVERPAY: OverpayPayment,
PaymentMethod.AURAPAY: AuraPayPayment,
}
model = model_map.get(payment_method)
+10
View File
@@ -99,25 +99,35 @@ async def upload_media(
bot = create_bot()
try:
# Send with disable_notification to avoid pinging admins — this is just staging
if media_type_normalized == 'photo':
message = await bot.send_photo(
chat_id=target_chat_id,
photo=upload,
disable_notification=True,
)
media = message.photo[-1]
elif media_type_normalized == 'video':
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
disable_notification=True,
)
media = message.video
else:
message = await bot.send_document(
chat_id=target_chat_id,
document=upload,
disable_notification=True,
)
media = message.document
# Delete the staging message immediately — file_id persists after deletion
try:
await bot.delete_message(chat_id=target_chat_id, message_id=message.message_id)
except Exception:
pass # Best-effort cleanup — file_id is already captured
media_url = _build_media_url(request, media.file_id)
logger.info(
+1 -1
View File
@@ -144,7 +144,7 @@ async def get_available_polls(
selectinload(PollResponse.poll).selectinload(Poll.questions),
selectinload(PollResponse.answers),
)
.order_by(PollResponse.created_at.desc())
.order_by(PollResponse.sent_at.desc())
)
responses = result.scalars().all()
+24
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.promocode_service import PromoCodeService
@@ -67,6 +68,29 @@ async def activate_promocode(
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
# Send admin notification (same as bot handler)
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_promocode_activation_notification(
db,
user,
result.get('promocode', {'code': request.code.strip()}),
result.get('description', ''),
result.get('balance_before_kopeks'),
result.get('balance_after_kopeks'),
)
finally:
await bot.session.close()
except Exception:
pass
return PromocodeActivateResponse(
success=True,
message='Promo code activated successfully',
@@ -49,7 +49,8 @@ async def update_autopay(
)
# Триальные подписки — пробник, автопродление не имеет смысла
if subscription.is_trial:
# NULL-safe: is_trial can be None in legacy rows — treat as trial
if subscription.is_trial is not False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Autopay is not available for trial subscriptions',
@@ -13,6 +13,7 @@ POST /subscription/devices/save-cart
from __future__ import annotations
import math
from datetime import UTC, datetime
from typing import Any
@@ -380,7 +381,7 @@ async def purchase_devices(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30 # Base period for device price calculation
# Устройства в пределах тарифного лимита — бесплатные
@@ -658,7 +659,7 @@ async def save_devices_cart(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30
# Устройства в пределах тарифного лимита — бесплатные
@@ -772,7 +773,7 @@ async def get_device_price(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30
# Устройства в пределах тарифного лимита — бесплатные
@@ -895,8 +895,14 @@ async def purchase_tariff(
except Exception as trial_err:
logger.warning('Failed to disable trial on RemnaWave', error=trial_err, trial_id=trial_sub.id)
try:
if subscription.remnawave_uuid:
# Existing subscription with Remnawave user — update it
# Mirror the bot handler logic: in single-tariff mode, check user.remnawave_uuid
# (webhook clears it on panel deletion), not subscription.remnawave_uuid
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if not _should_create:
await service.update_remnawave_user(
db,
subscription,
@@ -905,7 +911,6 @@ async def purchase_tariff(
sync_squads=True,
)
else:
# New subscription — create new Remnawave user
await service.create_remnawave_user(
db,
subscription,
@@ -919,7 +924,7 @@ async def purchase_tariff(
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if not subscription.remnawave_uuid else 'update',
action='create' if _should_create else 'update',
)
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
@@ -9,6 +9,7 @@ POST /subscription/traffic/save-cart
from __future__ import annotations
import math
from datetime import UTC, datetime
from typing import Any
@@ -478,7 +479,7 @@ async def save_traffic_cart(
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
prorated_price, _ = _calc_prorated(
base_price_kopeks,
subscription.end_date,
+1
View File
@@ -29,6 +29,7 @@ class BulkActionParams(BaseModel):
promo_group_id: int | None = None
device_limit: int | None = Field(None, ge=1, le=50)
delete_from_panel: bool = Field(default=True)
force_delete_active_paid: bool = Field(default=False)
class BulkSubscriptionInfo(BaseModel):
+1
View File
@@ -89,6 +89,7 @@ class SubscriptionListItem(BaseModel):
tariff_id: int | None = None
tariff_name: str | None = None
status: str
is_trial: bool = False
end_date: datetime | None = None
days_remaining: int = 0
traffic_used_gb: float = 0
+12 -36
View File
@@ -1,5 +1,3 @@
import hashlib
import hmac
import html
import os
import re
@@ -67,6 +65,18 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
# Per-category enable/disable (default True for backwards compatibility)
ADMIN_NOTIFICATIONS_PURCHASES_ENABLED: bool = True
ADMIN_NOTIFICATIONS_RENEWALS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_TRIALS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_BALANCE_ENABLED: bool = True
ADMIN_NOTIFICATIONS_ADDONS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_INFRASTRUCTURE_ENABLED: bool = True
ADMIN_NOTIFICATIONS_ERRORS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_PROMO_ENABLED: bool = True
ADMIN_NOTIFICATIONS_PARTNERS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_TICKETS_ENABLED: bool = True
# Настройки очереди чеков NaloGO
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
@@ -844,9 +854,6 @@ class Settings(BaseSettings):
BACKUP_SEND_TOPIC_ID: int | None = None
BACKUP_ARCHIVE_PASSWORD: str | None = None
EXTERNAL_ADMIN_TOKEN: str | None = None
EXTERNAL_ADMIN_TOKEN_BOT_ID: int | None = None
# Cabinet (Personal Account) settings
CABINET_ENABLED: bool = False
CABINET_JWT_SECRET: str | None = None
@@ -1653,37 +1660,6 @@ class Settings(BaseSettings):
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL
def build_external_admin_token(self, bot_username: str) -> str:
"""Генерирует детерминированный и криптографически стойкий токен внешней админки."""
normalized = (bot_username or '').strip().lstrip('@').lower()
if not normalized:
raise ValueError('Bot username is required to build external admin token')
secret = (self.BOT_TOKEN or '').strip()
if not secret:
raise ValueError('Bot token is required to build external admin token')
digest = hmac.new(
key=secret.encode('utf-8'),
msg=f'remnawave.external_admin::{normalized}'.encode(),
digestmod=hashlib.sha256,
).hexdigest()
return digest[:48]
def get_external_admin_token(self) -> str | None:
token = (self.EXTERNAL_ADMIN_TOKEN or '').strip()
return token or None
def get_external_admin_bot_id(self) -> int | None:
try:
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
logger.warning(
'Некорректный идентификатор бота для внешней админки',
EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
)
return None
def is_traffic_selectable(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == 'selectable'
+2 -1
View File
@@ -1,3 +1,4 @@
import math
import secrets
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
@@ -1296,7 +1297,7 @@ async def add_subscription_servers(
if paid_prices is None:
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
paid_prices = []
from app.database.models import ServerSquad
+4
View File
@@ -27,6 +27,10 @@ REAL_PAYMENT_METHODS = [
PaymentMethod.KASSA_AI.value,
PaymentMethod.RIOPAY.value,
PaymentMethod.SEVERPAY.value,
PaymentMethod.ROLLYPAY.value,
PaymentMethod.PAYPEAR.value,
PaymentMethod.OVERPAY.value,
PaymentMethod.AURAPAY.value,
]
+21
View File
@@ -541,6 +541,27 @@ class RemnaWaveAPI:
return []
raise
async def get_subscription_request_history(
self,
uuid: str,
offset: int = 0,
limit: int = 20,
) -> dict:
"""Get subscription request history for a panel user.
Returns dict with 'total' and 'records' list.
Each record has: id, userUuid, requestAt, requestIp, userAgent.
"""
try:
response = await self._make_request(
'GET',
f'/api/users/{uuid}/subscription-request-history',
params={'offset': offset, 'limit': limit},
)
return response.get('response', {'total': 0, 'records': []})
except RemnaWaveAPIError:
return {'total': 0, 'records': []}
async def update_user(
self,
uuid: str,
+5 -4
View File
@@ -313,7 +313,7 @@ async def restore_backup_start(callback: types.CallbackQuery, db_user: User, db:
else:
text = """📥 <b>Восстановление из бекапа</b>
📎 Отправьте файл бекапа (.json или .json.gz)
📎 Отправьте файл бекапа (.json, .json.gz или .tar.gz)
<b>ВАЖНО:</b>
Файл должен быть создан этой системой бекапов
@@ -383,7 +383,7 @@ async def restore_backup_execute(callback: types.CallbackQuery, db_user: User, d
async def handle_backup_file_upload(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
if not message.document:
await message.answer(
'❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)',
'❌ Пожалуйста, отправьте файл бекапа (.json, .json.gz или .tar.gz)',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]]
),
@@ -391,10 +391,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A
return
document = message.document
allowed_extensions = ('.json', '.json.gz', '.tar.gz', '.tar')
if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')):
if not document.file_name or not any(document.file_name.endswith(ext) for ext in allowed_extensions):
await message.answer(
'❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл',
'❌ Неподдерживаемый формат файла. Загрузите .json, .json.gz или .tar.gz файл',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]]
),
-1
View File
@@ -158,7 +158,6 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'LOG',
'MODERATION',
'DEBUG',
'EXTERNAL_ADMIN',
),
},
}
+3 -4
View File
@@ -120,8 +120,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
# Show bot link
referral_text += (
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>')
+ f'\n<code>{html_escape(bot_referral_link)}</code>\n'
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>') + f'\n{html_escape(bot_referral_link)}\n'
)
# Show cabinet link if configured
@@ -129,7 +128,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
referral_text += (
'\n'
+ texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 <b>Ссылка на кабинет:</b>')
+ f'\n<code>{html_escape(cabinet_referral_link)}</code>\n'
+ f'\n{html_escape(cabinet_referral_link)}\n'
)
referral_text += (
@@ -551,7 +550,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
'Нажмите на текст ниже, чтобы скопировать:',
)
+ '\n\n'
f'<blockquote><code>{html_escape(invite_text)}</code></blockquote>'
f'<blockquote>{html_escape(invite_text)}</blockquote>'
),
keyboard,
)
+12
View File
@@ -101,6 +101,18 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
enable = callback.data.startswith('autopay_enable')
if enable:
# Trial subscriptions cannot use autopay
if subscription.is_trial or subscription.is_trial is None:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
'AUTOPAY_NOT_AVAILABLE_TRIAL',
'Автоплатеж недоступен для пробных подписок.',
),
show_alert=True,
)
return
# Classic subscriptions cannot use autopay when tariff mode is enabled
if settings.is_tariffs_mode() and not subscription.tariff_id:
texts = get_texts(db_user.language)
+2 -1
View File
@@ -1,6 +1,7 @@
import asyncio
import base64
import html as html_mod
import math
import re
import time
from datetime import UTC, datetime
@@ -545,7 +546,7 @@ def get_traffic_switch_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
+2 -1
View File
@@ -1,4 +1,5 @@
import html
import math
from datetime import UTC, datetime
from aiogram import types
@@ -266,7 +267,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
logger.info('🔧 Добавлено: Удалено', added=added, removed=removed)
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
days_to_pay = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_to_pay if days_to_pay > 0 else None
+6 -5
View File
@@ -1,4 +1,5 @@
import html as html_mod
import math
from datetime import UTC, datetime
from aiogram import types
@@ -343,7 +344,7 @@ async def confirm_change_devices(
# Считаем стоимость по оставшимся дням подписки
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
@@ -572,7 +573,7 @@ async def execute_change_devices(
chargeable_devices = devices_difference
devices_price_per_month = chargeable_devices * price_per_device
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
@@ -601,7 +602,7 @@ async def execute_change_devices(
)
return
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
await create_transaction(
db=db,
user_id=db_user.id,
@@ -1253,7 +1254,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
if is_daily_tariff:
# Для суточных тарифов считаем по дням (как в кабинете)
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
@@ -1274,7 +1275,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
else:
# Для обычных тарифов - по дням (как в кабинете)
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
+135 -33
View File
@@ -939,6 +939,13 @@ async def handle_custom_confirm(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
# Save promo offer state before deduction (for restore on failure)
@@ -958,11 +965,17 @@ async def handle_custom_confirm(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка списания баланса при покупке кастомного тарифа', error=e, exc_info=True)
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -1049,7 +1062,10 @@ async def handle_custom_confirm(
price_kopeks=total_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
return
try:
@@ -1148,11 +1164,12 @@ async def handle_custom_confirm(
),
parse_mode='HTML',
)
await callback.answer('Подписка оформлена!', show_alert=True)
except Exception as e:
logger.error('Ошибка при покупке тарифа с кастомными параметрами', error=e, exc_info=True)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
@error_handler
@@ -1377,6 +1394,13 @@ async def confirm_tariff_purchase(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
# Списываем баланс
@@ -1395,11 +1419,17 @@ async def confirm_tariff_purchase(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка списания баланса при покупке тарифа', error=e, exc_info=True)
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -1457,10 +1487,12 @@ async def confirm_tariff_purchase(
db_user.promo_offer_discount_source = saved_promo_source
db_user.promo_offer_discount_expires_at = saved_promo_expires
await db.commit()
await callback.answer(
f'Максимум подписок: {settings.get_max_active_subscriptions()}',
show_alert=True,
)
try:
await callback.message.edit_text(
f'❌ Максимум подписок: {settings.get_max_active_subscriptions()}'
)
except Exception:
pass
return
# Create NEW subscription for this tariff (multi-tariff: new Remnawave user)
@@ -1537,7 +1569,10 @@ async def confirm_tariff_purchase(
reason='Возврат: тариф уже активен',
error=refund_error,
)
await callback.answer('У вас уже есть активная подписка на этот тариф', show_alert=True)
try:
await callback.message.edit_text('❌ У вас уже есть активная подписка на этот тариф')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка создания/продления подписки при покупке тарифа', error=e, exc_info=True)
@@ -1581,7 +1616,10 @@ async def confirm_tariff_purchase(
reason='Возврат: ошибка покупки тарифа',
error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
return
# Обновляем пользователя в Remnawave
@@ -1686,7 +1724,6 @@ async def confirm_tariff_purchase(
),
parse_mode='HTML',
)
await callback.answer('Подписка оформлена!', show_alert=True)
# ==================== Покупка суточного тарифа ====================
@@ -1741,6 +1778,13 @@ async def confirm_daily_tariff_purchase(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -1754,11 +1798,17 @@ async def confirm_daily_tariff_purchase(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка списания баланса при покупке суточного тарифа', error=e, exc_info=True)
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -1864,7 +1914,10 @@ async def confirm_daily_tariff_purchase(
price_kopeks=final_daily_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
return
# Обновляем пользователя в Remnawave
@@ -1964,7 +2017,6 @@ async def confirm_daily_tariff_purchase(
),
parse_mode='HTML',
)
await callback.answer('Подписка оформлена!', show_alert=True)
# ==================== Продление по тарифу ====================
@@ -2364,6 +2416,13 @@ async def confirm_tariff_extend(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -2377,7 +2436,10 @@ async def confirm_tariff_extend(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Запоминаем, был ли триал ДО продления
@@ -2487,11 +2549,12 @@ async def confirm_tariff_extend(
),
parse_mode='HTML',
)
await callback.answer('Подписка продлена!', show_alert=True)
except Exception as e:
logger.error('Ошибка при продлении тарифа', error=e, exc_info=True)
await callback.answer('Произошла ошибка при продлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при продлении подписки')
except Exception:
pass
# ==================== Переключение тарифов ====================
@@ -3031,6 +3094,13 @@ async def confirm_tariff_switch(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -3044,7 +3114,10 @@ async def confirm_tariff_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -3198,11 +3271,13 @@ async def confirm_tariff_switch(
),
parse_mode='HTML',
)
await callback.answer('Тариф изменён!', show_alert=True)
except Exception as e:
logger.error('Ошибка при переключении тарифа', error=e, exc_info=True)
await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа')
except Exception:
pass
# ==================== Смена на суточный тариф ====================
@@ -3276,6 +3351,13 @@ async def confirm_daily_tariff_switch(
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -3289,7 +3371,10 @@ async def confirm_daily_tariff_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -3445,7 +3530,6 @@ async def confirm_daily_tariff_switch(
),
parse_mode='HTML',
)
await callback.answer('Тариф изменён!', show_alert=True)
except Exception as e:
logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True)
@@ -3478,7 +3562,10 @@ async def confirm_daily_tariff_switch(
price_kopeks=final_daily_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при смене тарифа', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при смене тарифа')
except Exception:
pass
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
@@ -3976,6 +4063,13 @@ async def confirm_instant_switch(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -3991,7 +4085,10 @@ async def confirm_instant_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из нового тарифа
@@ -4058,7 +4155,10 @@ async def confirm_instant_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('❌ Недостаточно средств', show_alert=True)
try:
await callback.message.edit_text('❌ Недостаточно средств')
except Exception:
pass
return
await create_transaction(
db,
@@ -4232,11 +4332,13 @@ async def confirm_instant_switch(
),
parse_mode='HTML',
)
await callback.answer('Тариф изменён!', show_alert=True)
except Exception as e:
logger.error('Ошибка при мгновенном переключении тарифа', error=e, exc_info=True)
await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа')
except Exception:
pass
async def return_to_saved_tariff_cart(
+4 -3
View File
@@ -1,3 +1,4 @@
import math
from datetime import UTC, datetime
from aiogram import types
@@ -807,7 +808,7 @@ async def confirm_switch_traffic(
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_remaining if days_remaining > 0 else None
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
@@ -911,7 +912,7 @@ async def execute_switch_traffic(
base_traffic = current_traffic - purchased_traffic
old_price_per_month = settings.get_traffic_price(base_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'traffic',
@@ -936,7 +937,7 @@ async def execute_switch_traffic(
await callback.answer('⚠️ Ошибка списания средств', show_alert=True)
return
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
await create_transaction(
db=db,
user_id=db_user.id,
+4 -3
View File
@@ -1,3 +1,4 @@
import math
from datetime import UTC, datetime
import structlog
@@ -2169,7 +2170,7 @@ def get_add_traffic_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
@@ -2311,7 +2312,7 @@ def get_change_devices_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
@@ -2473,7 +2474,7 @@ def get_manage_countries_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
logger.info(
'🔍 Расчет для управления странами: осталось дней до',
@@ -67,6 +67,12 @@ class AdminNotificationService:
NotificationCategory.TICKETS: self.ticket_topic_id,
}
# Per-category enabled flags (default True — backwards compatible)
self.category_enabled: dict[NotificationCategory, bool] = {}
for cat in NotificationCategory:
key = f'ADMIN_NOTIFICATIONS_{cat.value.upper()}_ENABLED'
self.category_enabled[cat] = getattr(settings, key, True)
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str:
if not referred_by_id:
return 'Нет'
@@ -1266,6 +1272,11 @@ class AdminNotificationService:
logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен')
return False
# Per-category suppression
if category and not self.category_enabled.get(category, True):
logger.debug('Уведомление подавлено (категория отключена)', category=category.value)
return False
try:
message_kwargs = {
'chat_id': self.chat_id,
+51
View File
@@ -31,6 +31,7 @@ from app.database.models import (
AdminRole,
AdvertisingCampaign,
AdvertisingCampaignRegistration,
AuraPayPayment,
BroadcastHistory,
ButtonClickLog,
CabinetRefreshToken,
@@ -40,18 +41,27 @@ from app.database.models import (
ContestTemplate,
CryptoBotPayment,
DiscountOffer,
EmailTemplate,
FaqPage,
FaqSetting,
FreekassaPayment,
GuestPurchase,
HeleketPayment,
InfoPage,
KassaAiPayment,
LandingPage,
MainMenuButton,
MenuLayoutHistory,
MonitoringLog,
MulenPayPayment,
NewsArticle,
NewsCategory,
NewsTag,
OverpayPayment,
Pal24Payment,
PartnerApplication,
PaymentMethodConfig,
PayPearPayment,
PinnedMessage,
PlategaPayment,
Poll,
@@ -71,9 +81,13 @@ from app.database.models import (
ReferralContestVirtualParticipant,
ReferralEarning,
RequiredChannel,
RioPayPayment,
RollyPayPayment,
SavedPaymentMethod,
SentNotification,
ServerSquad,
ServiceRule,
SeverPayPayment,
Squad,
Subscription,
SubscriptionConversion,
@@ -102,6 +116,7 @@ from app.database.models import (
WheelPrize,
WheelSpin,
WithdrawalRequest,
YandexClientIdMap,
YooKassaPayment,
payment_method_promo_groups,
server_squad_promo_groups,
@@ -183,6 +198,13 @@ class BackupService:
CloudPaymentsPayment,
FreekassaPayment,
KassaAiPayment,
RioPayPayment,
SeverPayPayment,
PayPearPayment,
RollyPayPayment,
OverpayPayment,
AuraPayPayment,
SavedPaymentMethod,
# --- Settings/content ---
PaymentMethodConfig,
PrivacyPolicy,
@@ -192,6 +214,17 @@ class BackupService:
PinnedMessage,
MainMenuButton,
MenuLayoutHistory,
EmailTemplate,
InfoPage,
# --- News (FK: none / self-contained) ---
NewsCategory,
NewsTag,
NewsArticle,
# --- Landing / Guest purchases (FK: users, tariffs, landings) ---
LandingPage,
GuestPurchase,
# --- Yandex analytics (FK: users) ---
YandexClientIdMap,
# --- User data (FK: users, promo_groups, subscriptions) ---
UserPromoGroup,
TrafficPurchase,
@@ -1476,6 +1509,13 @@ class BackupService:
'cloudpayments_payments',
'freekassa_payments',
'kassa_ai_payments',
'riopay_payments',
'severpay_payments',
'paypear_payments',
'rollypay_payments',
'overpay_payments',
'aurapay_payments',
'saved_payment_methods',
# --- Content/config ---
'pinned_messages',
'main_menu_buttons',
@@ -1485,6 +1525,17 @@ class BackupService:
'privacy_policies',
'public_offers',
'payment_method_configs',
'email_templates',
'info_pages',
# --- News ---
'news_articles',
'news_categories',
'news_tags',
# --- Landing / Guest purchases ---
'guest_purchases',
'landing_pages',
# --- Yandex analytics ---
'yandex_client_id_map',
# --- Support ---
'support_audit_logs',
'ticket_messages',
-146
View File
@@ -1,146 +0,0 @@
"""Утилиты для синхронизации токена внешней админки."""
from __future__ import annotations
import structlog
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import SystemSetting
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
logger = structlog.get_logger(__name__)
async def ensure_external_admin_token(
bot_username: str | None,
bot_id: int | None,
) -> str | None:
"""Генерирует и сохраняет токен внешней админки, если требуется."""
username_raw = (bot_username or '').strip()
if not username_raw:
logger.warning(
'⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует',
)
return None
normalized_username = username_raw.lstrip('@').lower()
if not normalized_username:
logger.warning(
'⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации',
)
return None
try:
token = settings.build_external_admin_token(normalized_username)
except Exception as error: # pragma: no cover - защитный блок
logger.error('❌ Ошибка генерации токена внешней админки', error=error)
return None
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SystemSetting.key, SystemSetting.value).where(
SystemSetting.key.in_(['EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'])
)
)
rows = dict(result.all())
existing_token = rows.get('EXTERNAL_ADMIN_TOKEN')
existing_bot_id_raw = rows.get('EXTERNAL_ADMIN_TOKEN_BOT_ID')
existing_bot_id: int | None = None
if existing_bot_id_raw is not None:
try:
existing_bot_id = int(existing_bot_id_raw)
except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений
logger.warning(
'⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки',
existing_bot_id_raw=existing_bot_id_raw,
)
if existing_token == token and existing_bot_id == bot_id:
if settings.get_external_admin_token() != token:
settings.EXTERNAL_ADMIN_TOKEN = token
if existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID:
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
return token
if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id:
logger.error(
'❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен , текущий',
existing_bot_id=existing_bot_id,
bot_id=bot_id,
)
try:
await bot_configuration_service.reset_value(
session,
'EXTERNAL_ADMIN_TOKEN',
force=True,
)
await bot_configuration_service.reset_value(
session,
'EXTERNAL_ADMIN_TOKEN_BOT_ID',
force=True,
)
await session.commit()
logger.warning(
'⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота',
)
except Exception as cleanup_error: # pragma: no cover - защитный блок
await session.rollback()
logger.error(
'❌ Не удалось очистить токен внешней админки после обнаружения подмены',
cleanup_error=cleanup_error,
)
finally:
settings.EXTERNAL_ADMIN_TOKEN = None
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None
return None
updates: list[tuple[str, object]] = []
if existing_token != token:
updates.append(('EXTERNAL_ADMIN_TOKEN', token))
if bot_id is not None and existing_bot_id != bot_id:
updates.append(('EXTERNAL_ADMIN_TOKEN_BOT_ID', bot_id))
if not updates:
# Токен совпал, но могли отсутствовать значения в настройках приложения
if settings.get_external_admin_token() != (existing_token or token):
settings.EXTERNAL_ADMIN_TOKEN = existing_token or token
if existing_bot_id is not None and (existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID):
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
elif bot_id is not None and bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID and existing_bot_id is None:
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id
return existing_token or token
try:
for key, value in updates:
await bot_configuration_service.set_value(
session,
key,
value,
force=True,
)
await session.commit()
logger.info('✅ Токен внешней админки синхронизирован для @', normalized_username=normalized_username)
except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение
await session.rollback()
logger.warning(
'⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа',
)
return None
return token
except SQLAlchemyError as error:
logger.error('❌ Ошибка сохранения токена внешней админки', error=error)
return None
+40 -1
View File
@@ -373,7 +373,22 @@ class MonitoringService:
user = await get_user_by_id(db, subscription.user_id)
if user and self.bot:
await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name)
# Skip notification if user has another ACTIVE subscription (multi-tariff)
skip_notify = False
if settings.is_multi_tariff_enabled():
other_active = await db.execute(
select(Subscription.id)
.where(
Subscription.user_id == user.id,
Subscription.id != subscription.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > datetime.now(UTC),
)
.limit(1)
)
skip_notify = other_active.scalar_one_or_none() is not None
if not skip_notify:
await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name)
logger.info(
"🔴 Подписка пользователя истекла и статус изменен на 'expired'", user_id=subscription.user_id
@@ -965,8 +980,12 @@ class MonitoringService:
try:
now = datetime.now(UTC)
# Lookback window — don't re-check subscriptions expired more than 30 days ago
lookback = now - timedelta(days=30)
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -974,7 +993,10 @@ class MonitoringService:
.where(
and_(
Subscription.is_trial == False,
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
Subscription.end_date >= lookback,
User.status == UserStatus.ACTIVE.value,
)
)
)
@@ -998,6 +1020,21 @@ class MonitoringService:
if subscription.end_date is None:
continue
# Skip if user has another ACTIVE subscription — they still have service
if settings.is_multi_tariff_enabled():
other_active = await db.execute(
select(Subscription.id)
.where(
Subscription.user_id == user.id,
Subscription.id != subscription.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
.limit(1)
)
if other_active.scalar_one_or_none() is not None:
continue
time_since_end = now - subscription.end_date
if time_since_end.total_seconds() < 0:
continue
@@ -1090,6 +1127,7 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -1100,6 +1138,7 @@ class MonitoringService:
Subscription.is_trial == False,
Subscription.end_date > current_time,
Subscription.end_date <= threshold_date,
User.status == UserStatus.ACTIVE.value,
)
)
)
+4 -4
View File
@@ -558,8 +558,8 @@ class Pal24PaymentMixin:
payment_id_str = str(payment.payment_id)
try:
payment_response = await service.get_payment_status(payment_id_str)
except Pal24APIError as error:
logger.error('Ошибка Pal24 API при получении статуса платежа', error=error)
except Pal24APIError:
logger.debug('Pal24 payment_id не найден или невалиден', payment_id=payment_id_str)
else:
if payment_response:
remote_payloads['payment_status'] = payment_response
@@ -569,8 +569,8 @@ class Pal24PaymentMixin:
try:
payments_response = await service.get_bill_payments(bill_id_str)
except Pal24APIError as error:
logger.error('Ошибка Pal24 API при получении списка платежей', error=error)
except Pal24APIError:
logger.debug('Pal24 bill payments не найдены', bill_id=bill_id_str)
else:
if payments_response:
remote_payloads['bill_payments'] = payments_response
+47 -17
View File
@@ -208,27 +208,57 @@ class PayPearService:
logger.exception('PayPear API connection error', error=e)
raise
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
"""Верификация подписи webhook PayPear через HMAC-SHA256.
# PayPear documented webhook source IPs
WEBHOOK_ALLOWED_IPS: set[str] = {'158.160.85.101'}
PayPear sends signature in the webhook JSON field 'signature'.
The signature is HMAC-SHA256(secret_key, raw_body).
def verify_webhook_signature(self, raw_body: bytes, received_signature: str, client_ip: str | None = None) -> bool:
"""Верификация webhook PayPear.
PayPear documentation does not specify the exact signature algorithm.
We try HMAC-SHA256(secret_key, body_without_signature_field) the most common pattern.
If signature verification fails, fall back to IP allowlist check (recommended by PayPear docs).
"""
try:
if not received_signature:
logger.warning('PayPear webhook: отсутствует signature')
return False
import json as json_mod
expected = hmac.new(
self.secret_key.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest()
# Try signature verification (body without 'signature' field, sorted keys, compact separators)
if received_signature and self.secret_key:
try:
payload = json_mod.loads(raw_body)
payload_without_sig = {k: v for k, v in payload.items() if k != 'signature'}
body_to_sign = json_mod.dumps(payload_without_sig, separators=(',', ':'), sort_keys=True).encode(
'utf-8'
)
return hmac.compare_digest(expected, received_signature)
except Exception as e:
logger.error('PayPear webhook verify error', error=e)
return False
expected = hmac.new(
self.secret_key.encode('utf-8'),
body_to_sign,
hashlib.sha256,
).hexdigest()
if hmac.compare_digest(expected, received_signature):
return True
# Try without sort_keys (original key order)
body_to_sign_unsorted = json_mod.dumps(payload_without_sig, separators=(',', ':')).encode('utf-8')
expected_unsorted = hmac.new(
self.secret_key.encode('utf-8'),
body_to_sign_unsorted,
hashlib.sha256,
).hexdigest()
if hmac.compare_digest(expected_unsorted, received_signature):
return True
logger.debug('PayPear signature mismatch, falling back to IP check')
except Exception as e:
logger.debug('PayPear signature verify error, falling back to IP check', error=e)
# Fallback: IP allowlist (recommended by PayPear docs)
if client_ip and client_ip in self.WEBHOOK_ALLOWED_IPS:
return True
logger.warning('PayPear webhook: signature mismatch and IP not in allowlist', client_ip=client_ip)
return False
# Singleton instance
+3
View File
@@ -77,6 +77,9 @@ PERMISSION_REGISTRY: dict[str, list[str]] = {
'pinned_messages': ['read', 'create', 'edit', 'delete'],
'landings': ['read', 'create', 'edit', 'delete'],
'updates': ['read', 'manage'],
'bulk_actions': ['read', 'execute'],
'info_pages': ['read', 'create', 'edit', 'delete'],
'news': ['read', 'create', 'edit', 'delete'],
}
+5 -2
View File
@@ -603,6 +603,9 @@ class PricingEngine:
period_pct = 0
devices_pct = 0
promo_group = self.resolve_promo_group(user)
# Only apply promo group discount if the tariff is available for this group
if promo_group is not None and not tariff.is_available_for_promo_group(promo_group.id):
promo_group = None
if promo_group is not None:
period_pct = promo_group.get_discount_percent('period', period_days)
devices_pct = promo_group.get_discount_percent('devices', period_days)
@@ -612,9 +615,9 @@ class PricingEngine:
discounted_base = self.apply_discount(base_price, period_pct)
discounted_devices = self.apply_discount(devices_price, devices_pct)
# Traffic uses addon discount (checks apply_discounts_to_addons flag)
# Traffic uses addon discount — but only if promo_group passed the tariff availability check
discounted_traffic = traffic_price
if traffic_price > 0 and user:
if traffic_price > 0 and user and promo_group is not None:
discounted_traffic, _, _ = self.calculate_traffic_discount(traffic_price, user)
base_group_disc = base_price - discounted_base
+4
View File
@@ -301,6 +301,10 @@ class ReferralContestService:
lines.append('')
lines.append(f'Приз: {html.escape(contest.prize_text)}')
# Respect per-category enable/disable
if not getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_ENABLED', True):
return
try:
await self.bot.send_message(
chat_id=chat_id,
+3 -1
View File
@@ -2397,7 +2397,9 @@ class RemnaWaveService:
user.remnawave_uuid = panel_uuid
return ('updated', sub, None)
except RemnaWaveAPIError as api_error:
if api_error.status_code == 404:
# A018 = "user not found" in some RemnaWave versions (may return 400 or 404)
error_code = (api_error.response_data or {}).get('errorCode', '')
if api_error.status_code == 404 or error_code == 'A018':
new_user = await api.create_user(**create_kwargs)
return ('created', sub, new_user)
raise
+12 -2
View File
@@ -994,6 +994,16 @@ class RemnaWaveWebhookService:
async def _handle_user_deleted(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
# Suppress webhook if this deletion was initiated by delete_user_account —
# prevents deadlock between the ongoing deletion transaction and this handler
if self._is_intentional_panel_deletion_event(data):
logger.info(
'Webhook user.deleted suppressed — intentional panel deletion in progress',
user_id=user.id,
uuid=data.get('uuid'),
)
return
user_id = user.id
sub_id = subscription.id if subscription else None
@@ -1075,8 +1085,8 @@ class RemnaWaveWebhookService:
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
if settings.is_multi_tariff_enabled():
subscription.remnawave_uuid = None
# Always clear stale UUID — panel user was deleted
subscription.remnawave_uuid = None
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
@@ -3,6 +3,7 @@
from __future__ import annotations
import html
import math
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -1533,7 +1534,7 @@ async def _auto_add_devices(
# Recompute price fresh under lock (pricing config may have changed since cart was saved)
devices_price_per_month = devices_to_add * tariff_device_price
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
devices_discount_percent = PricingEngine.get_addon_discount_percent(
user,
'devices',
@@ -2137,7 +2138,7 @@ async def try_auto_extend_expired_after_topup(
from app.database.crud.subscription import get_all_subscriptions_by_user_id
all_subs = await get_all_subscriptions_by_user_id(db, user.id)
expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and not s.is_trial]
expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and s.is_trial is False]
if not expired_subs:
subscription = None
else:
@@ -2153,9 +2154,10 @@ async def try_auto_extend_expired_after_topup(
return False
# Only process expired subscriptions (not trial, not disabled)
# NULL-safe: is_trial can be None in legacy rows — treat as trial
if subscription.status != SubscriptionStatus.EXPIRED.value:
return False
if subscription.is_trial:
if subscription.is_trial is not False:
return False
# Only process subscriptions expired within the last 30 days
+2 -19
View File
@@ -70,8 +70,8 @@ class ReadOnlySettingError(RuntimeError):
class BotConfigurationService:
EXCLUDED_KEYS: set[str] = {'BOT_TOKEN', 'ADMIN_IDS'}
READ_ONLY_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'}
PLAIN_TEXT_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'}
READ_ONLY_KEYS: set[str] = set()
PLAIN_TEXT_KEYS: set[str] = set()
CATEGORY_TITLES: dict[str, str] = {
'CORE': '🤖 Основные настройки',
@@ -101,7 +101,6 @@ class BotConfigurationService:
'MULENPAY': '💰 {mulenpay_name}',
'PAL24': '🏦 PAL24 / PayPalych',
'WATA': '💠 Wata',
'EXTERNAL_ADMIN': '🛡️ Внешняя админка',
'SUBSCRIPTIONS_CORE': '📅 Подписки и лимиты',
'SIMPLE_SUBSCRIPTION': '⚡ Простая покупка',
'PERIODS': '📆 Периоды подписок',
@@ -168,7 +167,6 @@ class BotConfigurationService:
'TELEGRAM_WIDGET': 'Внешний вид виджета авторизации Telegram на странице входа в кабинет.',
'TELEGRAM_OIDC': 'OpenID Connect авторизация через Telegram (новая система). Требует настройки в BotFather > Bot Settings > Web Login.',
'WATA': 'Wata: токен доступа, тип платежа и пределы сумм.',
'EXTERNAL_ADMIN': 'Токен внешней админки для проверки запросов.',
'SUBSCRIPTIONS_CORE': 'Лимиты устройств, трафика и базовые цены подписок.',
'SIMPLE_SUBSCRIPTION': 'Параметры упрощённой покупки: период, трафик, устройства и сквады.',
'PERIODS': 'Доступные периоды подписок и продлений.',
@@ -381,7 +379,6 @@ class BotConfigurationService:
'PAYMENT_': 'PAYMENT',
'PAYMENT_VERIFICATION_': 'PAYMENT_VERIFICATION',
'WATA_': 'WATA',
'EXTERNAL_ADMIN_': 'EXTERNAL_ADMIN',
'SIMPLE_SUBSCRIPTION_': 'SIMPLE_SUBSCRIPTION',
'CONNECT_BUTTON_HAPP': 'HAPP',
'HAPP_': 'HAPP',
@@ -738,20 +735,6 @@ class BotConfigurationService:
'Если результат пустой, используется user_{telegram_id}.'
),
},
'EXTERNAL_ADMIN_TOKEN': {
'description': 'Приватный токен, который использует внешняя админка для проверки запросов.',
'format': 'Значение генерируется автоматически из username бота и его токена и доступно только для чтения.',
'example': 'Генерируется автоматически',
'warning': 'Токен обновится при смене username или токена бота.',
'dependencies': 'Username телеграм-бота, токен бота',
},
'EXTERNAL_ADMIN_TOKEN_BOT_ID': {
'description': 'Идентификатор телеграм-бота, с которым связан токен внешней админки.',
'format': 'Проставляется автоматически после первого запуска и не редактируется вручную.',
'example': '123456789',
'warning': 'Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.',
'dependencies': 'Результат вызова getMe() в Telegram Bot API',
},
'TRIAL_USER_TAG': {
'description': (
'Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave.'
+88
View File
@@ -184,6 +184,66 @@ def get_available_payment_methods() -> list[dict[str, str]]:
}
)
if settings.is_severpay_enabled():
severpay_name = settings.get_severpay_display_name()
methods.append(
{
'id': 'severpay',
'name': f'Банковская карта ({severpay_name})',
'icon': '💳',
'description': f'через {severpay_name}',
'callback': 'topup_severpay',
}
)
if settings.is_paypear_enabled():
paypear_name = settings.get_paypear_display_name()
methods.append(
{
'id': 'paypear',
'name': paypear_name,
'icon': '💳',
'description': f'через {paypear_name}',
'callback': 'topup_paypear',
}
)
if settings.is_rollypay_enabled():
rollypay_name = settings.get_rollypay_display_name()
methods.append(
{
'id': 'rollypay',
'name': rollypay_name,
'icon': '💳',
'description': f'через {rollypay_name}',
'callback': 'topup_rollypay',
}
)
if settings.is_overpay_enabled():
overpay_name = settings.get_overpay_display_name()
methods.append(
{
'id': 'overpay',
'name': overpay_name,
'icon': '💳',
'description': f'через {overpay_name}',
'callback': 'topup_overpay',
}
)
if settings.is_aurapay_enabled():
aurapay_name = settings.get_aurapay_display_name()
methods.append(
{
'id': 'aurapay',
'name': aurapay_name,
'icon': '💳',
'description': f'через {aurapay_name}',
'callback': 'topup_aurapay',
}
)
if settings.is_support_topup_enabled():
methods.append(
{
@@ -311,6 +371,16 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_kassa_ai_enabled()
if method_id == 'riopay':
return settings.is_riopay_enabled()
if method_id == 'severpay':
return settings.is_severpay_enabled()
if method_id == 'paypear':
return settings.is_paypear_enabled()
if method_id == 'rollypay':
return settings.is_rollypay_enabled()
if method_id == 'overpay':
return settings.is_overpay_enabled()
if method_id == 'aurapay':
return settings.is_aurapay_enabled()
if method_id == 'support':
return settings.is_support_topup_enabled()
return False
@@ -333,6 +403,12 @@ def get_payment_method_status() -> dict[str, bool]:
'cloudpayments': settings.is_cloudpayments_enabled(),
'freekassa': settings.is_freekassa_enabled(),
'kassa_ai': settings.is_kassa_ai_enabled(),
'riopay': settings.is_riopay_enabled(),
'severpay': settings.is_severpay_enabled(),
'paypear': settings.is_paypear_enabled(),
'rollypay': settings.is_rollypay_enabled(),
'overpay': settings.is_overpay_enabled(),
'aurapay': settings.is_aurapay_enabled(),
'support': settings.is_support_topup_enabled(),
}
@@ -366,4 +442,16 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_kassa_ai_enabled():
count += 1
if settings.is_riopay_enabled():
count += 1
if settings.is_severpay_enabled():
count += 1
if settings.is_paypear_enabled():
count += 1
if settings.is_rollypay_enabled():
count += 1
if settings.is_overpay_enabled():
count += 1
if settings.is_aurapay_enabled():
count += 1
return count
+3 -2
View File
@@ -1,3 +1,4 @@
import math
from collections.abc import Sequence
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional
@@ -19,14 +20,14 @@ def calculate_months_from_days(days: int) -> int:
return max(1, round(days / 30))
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 30) -> tuple[int, int]:
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 1) -> tuple[int, int]:
"""Calculate prorated price based on remaining days.
Returns:
tuple of (total_price_kopeks, days_charged)
"""
now = datetime.now(UTC)
days_remaining = max(1, (end_date - now).days)
days_remaining = max(1, math.ceil((end_date - now).total_seconds() / 86400))
days_to_charge = max(min_charge_days, days_remaining)
total_price = monthly_price * days_to_charge // 30
+4 -4
View File
@@ -5755,7 +5755,7 @@ async def update_subscription_servers_endpoint(
subscription.end_date,
)
else:
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
added_server_ids = [catalog[uuid].get('server_id') for uuid in added if catalog[uuid].get('server_id') is not None]
added_server_prices = [
@@ -5933,7 +5933,7 @@ async def update_subscription_traffic_endpoint(
},
)
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
period_hint_days = days_remaining
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
@@ -6111,7 +6111,7 @@ async def update_subscription_devices_endpoint(
chargeable_diff = new_chargeable - current_chargeable
price_per_month = chargeable_diff * tariff_device_price
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
period_hint_days = days_remaining
# Lock user BEFORE price computation to prevent TOCTOU on promo discount
@@ -6159,7 +6159,7 @@ async def update_subscription_devices_endpoint(
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_to_charge,
description=f'{description} за {charged_days or max(1, (subscription.end_date - datetime.now(UTC)).days)} дн.',
description=f'{description} за {charged_days or max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))} дн.',
)
if price_to_charge > 0:
+7 -2
View File
@@ -1268,8 +1268,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
from app.services.paypear_service import paypear_service
if not paypear_service.verify_webhook_signature(raw_body, received_signature):
logger.warning('PayPear webhook: invalid signature')
client_ip = (
request.headers.get('x-real-ip')
or request.headers.get('x-forwarded-for', '').split(',')[0].strip()
or (request.client.host if request.client else None)
)
if not paypear_service.verify_webhook_signature(raw_body, received_signature, client_ip=client_ip):
logger.warning('PayPear webhook: invalid signature and IP', client_ip=client_ip)
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
try:
-3
View File
@@ -402,9 +402,6 @@
- `app/services/campaign_service.py` — Python-модуль
Классы: `CampaignBonusResult`, `AdvertisingCampaignService` (1 методов)
Функции: нет
- `app/services/external_admin_service.py` — Утилиты для синхронизации токена внешней админки.
Классы: нет
Функции: нет
- `app/services/faq_service.py` — Python-модуль
Классы: `FaqService` (3 методов)
Функции: нет
-19
View File
@@ -22,7 +22,6 @@ from app.services.ban_notification_service import ban_notification_service
from app.services.broadcast_service import broadcast_service
from app.services.contest_rotation_service import contest_rotation_service
from app.services.daily_subscription_service import daily_subscription_service
from app.services.external_admin_service import ensure_external_admin_token
from app.services.log_rotation_service import log_rotation_service
from app.services.maintenance_service import maintenance_service
from app.services.monitoring_service import monitoring_service
@@ -515,24 +514,6 @@ async def main():
else:
stage.skip('NaloGO отключен настройками')
async with timeline.stage(
'Внешняя админка',
'🛡️',
success_message='Токен внешней админки готов',
) as stage:
try:
token = await ensure_external_admin_token(
bot_user.username,
bot_user.id,
)
if token:
stage.log('Токен синхронизирован')
else:
stage.warning('Не удалось получить токен внешней админки')
except Exception as error: # pragma: no cover - защитный блок
stage.warning(f'Ошибка подготовки внешней админки: {error}')
logger.error('❌ Ошибка подготовки внешней админки', error=error)
bot_run_mode = settings.get_bot_run_mode()
polling_enabled = bot_run_mode == 'polling'
telegram_webhook_enabled = bot_run_mode == 'webhook'