fix: multi-tariff Stage 2 HIGH fixes — 18 issues across 12 files

Bot handlers (H1-H5):
- confirm_extend_subscription: error alert instead of wrong sub fallback
- open_subscription_link/subscription_connect: startswith registration
- handle_subscription_settings: multi-tariff guard
- confirm_reset_traffic: FSM state check in multi-tariff

Services (H6-H12):
- subscription_service: 5 UUID fallback fixes — no user.remnawave_uuid in
  multi-tariff, return None if subscription.remnawave_uuid missing
- auto_purchase: use cart subscription_id for tariff match
- remnawave_service: migrate_squad_users checks subscription.remnawave_uuid
- campaign_service: extend existing sub or create new in multi-tariff
- broadcast_service: check ALL subs for paid-subscription guard
- blocked_users_service: remnawave_uuids list, iterate in cleanup
- user_service: log sub.remnawave_uuid in multi-tariff

Admin (H13-H16):
- grant_trial/paid_subscription: allow in multi-tariff mode
- promo_offers: pick sub with URL, aggregate squads from all subs

CRUD/Frontend (H17-H18):
- get_users_list: .unique() for outerjoin dedup
- refreshTraffic: withSubId in params instead of body
This commit is contained in:
Fringg
2026-03-26 08:09:07 +03:00
parent 4259ba1cb5
commit c6bedc6a06
12 changed files with 260 additions and 131 deletions
+1 -1
View File
@@ -901,7 +901,7 @@ async def get_users_list(
query = query.offset(offset).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
users = result.scalars().unique().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
+13 -3
View File
@@ -1902,7 +1902,11 @@ async def show_selected_user_details(
def _build_connect_button_rows(user: User, texts) -> list[list[InlineKeyboardButton]]:
subscription = getattr(user, 'subscription', None)
if settings.is_multi_tariff_enabled():
subs = getattr(user, 'subscriptions', None) or []
subscription = next((s for s in subs if s.subscription_url), None)
else:
subscription = getattr(user, 'subscription', None)
if not subscription:
return []
@@ -2122,8 +2126,14 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn
if template.offer_type == 'test_access' and squad_uuid:
filtered_users: list[User] = []
for user in users:
subscription = getattr(user, 'subscription', None)
connected = set(subscription.connected_squads or []) if subscription else set()
if settings.is_multi_tariff_enabled():
all_squads: set[str] = set()
for s in (getattr(user, 'subscriptions', None) or []):
all_squads.update(s.connected_squads or [])
connected = all_squads
else:
subscription = getattr(user, 'subscription', None)
connected = set(subscription.connected_squads or []) if subscription else set()
if squad_uuid in connected:
continue
filtered_users.append(user)
+2 -2
View File
@@ -4375,7 +4375,7 @@ async def _grant_trial_subscription(
from app.services.subscription_service import SubscriptionService
existing_subscription = await _resolve_admin_subscription(db, user_id, subscription_id)
if existing_subscription:
if existing_subscription and not settings.is_multi_tariff_enabled():
logger.error('У пользователя уже есть подписка', user_id=user_id)
return False
@@ -4409,7 +4409,7 @@ async def _grant_paid_subscription(
from app.services.subscription_service import SubscriptionService
existing_subscription = await _resolve_admin_subscription(db, user_id, subscription_id)
if existing_subscription:
if existing_subscription and not settings.is_multi_tariff_enabled():
logger.error('У пользователя уже есть подписка', user_id=user_id)
return False
+10 -7
View File
@@ -1813,7 +1813,9 @@ async def confirm_extend_subscription(
if _fsm_sub_id:
subscription = await get_subscription_by_id_for_user(db, _fsm_sub_id, db_user.id)
else:
subscription = db_user.subscription
# Multi-tariff without FSM state — cannot determine which subscription
await callback.answer('Выберите подписку через "Мои подписки"', show_alert=True)
return
else:
subscription = db_user.subscription
@@ -2845,10 +2847,11 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
return
texts = get_texts(db_user.language)
# Multi-tariff note: this handler is reached via 'subscription_settings' callback
# which is shown in the single-subscription info keyboard. In multi-tariff mode,
# show_subscription_info redirects to show_my_subscriptions, so per-subscription
# settings are handled from the my_subscriptions flow. db_user.subscription is safe.
if settings.is_multi_tariff_enabled():
await callback.answer('Настройки доступны через "Мои подписки"', show_alert=True)
return
subscription = db_user.subscription
# Получаем тариф подписки если есть
@@ -4137,7 +4140,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_happ_download_back, F.data == 'happ_download_back')
dp.callback_query.register(handle_connect_subscription, F.data == 'subscription_connect')
dp.callback_query.register(handle_connect_subscription, F.data.startswith('subscription_connect'))
dp.callback_query.register(handle_device_guide, F.data.startswith('device_guide_'))
@@ -4145,7 +4148,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_specific_app_guide, F.data.startswith('app_') & ~F.data.startswith('app_list_'))
dp.callback_query.register(handle_open_subscription_link, F.data == 'open_subscription_link')
dp.callback_query.register(handle_open_subscription_link, F.data.startswith('open_subscription_link'))
dp.callback_query.register(handle_subscription_settings, F.data == 'subscription_settings')
+6
View File
@@ -314,6 +314,12 @@ async def confirm_reset_traffic(
await callback.answer('⚠️ В текущем режиме трафик фиксированный', show_alert=True)
return
if settings.is_multi_tariff_enabled():
_state_data = await state.get_data() if state else {}
if not _state_data.get('active_subscription_id'):
await callback.answer('Выберите подписку через "Мои подписки"', show_alert=True)
return
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
+18 -3
View File
@@ -88,6 +88,7 @@ class BlockCheckResult:
status: BlockCheckStatus
error_message: str | None = None
remnawave_uuid: str | None = None
remnawave_uuids: list[str] = field(default_factory=list)
@dataclass
@@ -159,6 +160,13 @@ class BlockedUsersService:
async def _check_single_user(self, user: User) -> BlockCheckResult:
"""Проверяет одного пользователя."""
sub_uuids = [
s.remnawave_uuid
for s in (getattr(user, 'subscriptions', None) or [])
if s.remnawave_uuid
]
remnawave_uuids = sub_uuids or ([user.remnawave_uuid] if user.remnawave_uuid else [])
if not user.telegram_id:
return BlockCheckResult(
user_id=user.id,
@@ -167,6 +175,7 @@ class BlockedUsersService:
full_name=user.full_name,
status=BlockCheckStatus.NO_TELEGRAM_ID,
remnawave_uuid=user.remnawave_uuid,
remnawave_uuids=remnawave_uuids,
)
status = await self.check_user_blocked(user.telegram_id)
@@ -178,6 +187,7 @@ class BlockedUsersService:
full_name=user.full_name,
status=status,
remnawave_uuid=user.remnawave_uuid,
remnawave_uuids=remnawave_uuids,
)
async def scan_all_users(
@@ -417,12 +427,17 @@ class BlockedUsersService:
for i, user_result in enumerate(blocked_users):
try:
if action in (BlockedUserAction.DELETE_FROM_REMNAWAVE, BlockedUserAction.DELETE_BOTH):
if user_result.remnawave_uuid:
success = await self.delete_user_from_remnawave(user_result.remnawave_uuid)
uuids_to_delete = user_result.remnawave_uuids or (
[user_result.remnawave_uuid] if user_result.remnawave_uuid else []
)
for rw_uuid in uuids_to_delete:
success = await self.delete_user_from_remnawave(rw_uuid)
if success:
result.deleted_from_remnawave += 1
else:
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из Remnawave')
result.errors.append(
f'Ошибка удаления {user_result.telegram_id} (uuid={rw_uuid}) из Remnawave'
)
# Задержка для избежания rate limit
await asyncio.sleep(self.API_DELAY_SECONDS)
+3 -6
View File
@@ -544,14 +544,11 @@ async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> No
from app.database.crud.subscription import is_active_paid_subscription
sub_result = await session.execute(
select(Subscription)
.where(Subscription.user_id == user.id)
.order_by(Subscription.created_at.desc())
.limit(1)
select(Subscription).where(Subscription.user_id == user.id)
)
user_subscription = sub_result.scalar_one_or_none()
all_subs = sub_result.scalars().all()
if is_active_paid_subscription(user_subscription):
if any(is_active_paid_subscription(s) for s in all_subs):
logger.info(
'⏭️ Пропуск отключения подписки: у пользователя активная оплаченная подписка',
telegram_id=telegram_id,
+108 -65
View File
@@ -134,6 +134,7 @@ class AdvertisingCampaignService:
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
if active_subs:
# Multi-tariff: extend the best existing subscription instead of blocking
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
_pool = _non_daily or active_subs
existing_subscription = max(_pool, key=lambda s: s.days_left)
@@ -141,13 +142,13 @@ class AdvertisingCampaignService:
existing_subscription = None
else:
existing_subscription = await get_subscription_by_user_id(db, user.id)
if existing_subscription:
logger.warning(
'⚠️ У пользователя уже есть подписка, бонус кампании пропущен',
format_user_log=_format_user_log(user),
campaign_id=campaign.id,
)
return CampaignBonusResult(success=False)
if existing_subscription:
logger.warning(
'⚠️ У пользователя уже есть подписка, бонус кампании пропущен',
format_user_log=_format_user_log(user),
campaign_id=campaign.id,
)
return CampaignBonusResult(success=False)
duration_days = campaign.subscription_duration_days or 0
if duration_days <= 0:
@@ -170,21 +171,46 @@ class AdvertisingCampaignService:
except Exception as error:
logger.error('Не удалось подобрать сквад для кампании', campaign_id=campaign.id, error=error)
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
connected_squads=squads,
update_server_counters=True,
is_trial=True,
)
if existing_subscription:
# Multi-tariff: extend the best existing subscription
from app.database.crud.subscription import extend_subscription
try:
await self.subscription_service.create_remnawave_user(db, new_subscription)
except Exception as error:
logger.error('❌ Ошибка синхронизации RemnaWave для кампании', campaign_id=campaign.id, error=error)
await extend_subscription(db, existing_subscription, duration_days)
try:
await self.subscription_service.update_remnawave_user(db, existing_subscription)
except Exception as error:
logger.error('❌ Ошибка синхронизации RemnaWave при продлении кампании', campaign_id=campaign.id, error=error)
logger.info(
'🎁 Подписка пользователя продлена по кампании на дней',
format_user_log=_format_user_log(user),
campaign_id=campaign.id,
duration_days=duration_days,
subscription_id=existing_subscription.id,
)
else:
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
connected_squads=squads,
update_server_counters=True,
is_trial=True,
)
try:
await self.subscription_service.create_remnawave_user(db, new_subscription)
except Exception as error:
logger.error('❌ Ошибка синхронизации RemnaWave для кампании', campaign_id=campaign.id, error=error)
logger.info(
'🎁 Пользователю выдана подписка по кампании на дней',
format_user_log=_format_user_log(user),
campaign_id=campaign.id,
duration_days=duration_days,
)
await record_campaign_registration(
db,
@@ -194,13 +220,6 @@ class AdvertisingCampaignService:
subscription_duration_days=duration_days,
)
logger.info(
'🎁 Пользователю выдана подписка по кампании на дней',
format_user_log=_format_user_log(user),
campaign_id=campaign.id,
duration_days=duration_days,
)
return CampaignBonusResult(
success=True,
bonus_type='subscription',
@@ -242,25 +261,26 @@ class AdvertisingCampaignService:
campaign: AdvertisingCampaign,
) -> CampaignBonusResult:
"""Выдача тарифа на определённое время."""
existing_subscription = None
if settings.is_multi_tariff_enabled():
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
if active_subs:
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
_pool = _non_daily or active_subs
existing_subscription = max(_pool, key=lambda s: s.days_left)
else:
existing_subscription = None
if active_subs and campaign.tariff_id:
# Multi-tariff: only check for THIS specific tariff
same_tariff_subs = [s for s in active_subs if s.tariff_id == campaign.tariff_id]
if same_tariff_subs:
existing_subscription = max(same_tariff_subs, key=lambda s: s.days_left)
# If no sub for this tariff, existing_subscription stays None -> create new
else:
existing_subscription = await get_subscription_by_user_id(db, user.id)
if existing_subscription:
logger.warning(
'⚠️ У пользователя уже есть подписка, бонус тарифа кампании пропущен',
format_user_log=_format_user_log(user),
campaign_id=campaign.id,
)
return CampaignBonusResult(success=False)
if existing_subscription:
logger.warning(
'⚠️ У пользователя уже есть подписка, бонус тарифа кампании пропущен',
format_user_log=_format_user_log(user),
campaign_id=campaign.id,
)
return CampaignBonusResult(success=False)
if not campaign.tariff_id:
logger.error('❌ Кампания не имеет указанного тарифа для выдачи', campaign_id=campaign.id)
@@ -295,23 +315,54 @@ class AdvertisingCampaignService:
except Exception as error:
logger.error('Не удалось подобрать сквад для тарифа кампании', campaign_id=campaign.id, error=error)
# Создаём подписку как платную (не trial) с привязкой к тарифу
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
connected_squads=squads,
update_server_counters=True,
is_trial=False, # Это полноценная подписка, не пробная
tariff_id=tariff.id,
)
if existing_subscription:
# Multi-tariff: extend the existing subscription for this tariff
from app.database.crud.subscription import extend_subscription
try:
await self.subscription_service.create_remnawave_user(db, new_subscription)
except Exception as error:
logger.error('❌ Ошибка синхронизации RemnaWave для тарифа кампании', campaign_id=campaign.id, error=error)
await extend_subscription(db, existing_subscription, duration_days, tariff_id=tariff.id)
try:
await self.subscription_service.update_remnawave_user(db, existing_subscription)
except Exception as error:
logger.error(
'❌ Ошибка синхронизации RemnaWave при продлении тарифа кампании',
campaign_id=campaign.id,
error=error,
)
logger.info(
'🎁 Подписка пользователя продлена по тарифу кампании на дней',
format_user_log=_format_user_log(user),
tariff_name=tariff.name,
campaign_id=campaign.id,
duration_days=duration_days,
subscription_id=existing_subscription.id,
)
else:
# Создаём подписку как платную (не trial) с привязкой к тарифу
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
connected_squads=squads,
update_server_counters=True,
is_trial=False,
tariff_id=tariff.id,
)
try:
await self.subscription_service.create_remnawave_user(db, new_subscription)
except Exception as error:
logger.error('❌ Ошибка синхронизации RemnaWave для тарифа кампании', campaign_id=campaign.id, error=error)
logger.info(
'🎁 Пользователю выдан тариф по кампании на дней',
format_user_log=_format_user_log(user),
tariff_name=tariff.name,
campaign_id=campaign.id,
duration_days=duration_days,
)
await record_campaign_registration(
db,
@@ -322,14 +373,6 @@ class AdvertisingCampaignService:
tariff_duration_days=duration_days,
)
logger.info(
"🎁 Пользователю выдан тариф '' по кампании на дней",
format_user_log=_format_user_log(user),
tariff_name=tariff.name,
campaign_id=campaign.id,
duration_days=duration_days,
)
return CampaignBonusResult(
success=True,
bonus_type='tariff',
+6 -1
View File
@@ -1003,7 +1003,12 @@ class RemnaWaveService:
try:
needs_panel_update = any(
subscription.user and subscription.user.remnawave_uuid for subscription in subscriptions
(
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled()
else (subscription.user and subscription.user.remnawave_uuid)
)
for subscription in subscriptions
)
api = None
@@ -756,10 +756,17 @@ async def _auto_purchase_tariff(
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
existing_subscription = next(
(s for s in active_subs if s.tariff_id == tariff_id),
None,
)
_cart_sub_id = cart_data.get('subscription_id')
if _cart_sub_id:
existing_subscription = next(
(s for s in active_subs if s.id == int(_cart_sub_id)),
None,
)
else:
existing_subscription = next(
(s for s in active_subs if s.tariff_id == tariff_id),
None,
)
else:
existing_subscription = await get_subscription_by_user_id(db, user.id)
@@ -1153,10 +1160,17 @@ async def _auto_purchase_daily_tariff(
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
existing_subscription = next(
(s for s in active_subs if s.tariff_id == tariff_id),
None,
)
_cart_sub_id = cart_data.get('subscription_id')
if _cart_sub_id:
existing_subscription = next(
(s for s in active_subs if s.id == int(_cart_sub_id)),
None,
)
else:
existing_subscription = next(
(s for s in active_subs if s.tariff_id == tariff_id),
None,
)
else:
existing_subscription = await get_subscription_by_user_id(db, user.id)
+70 -34
View File
@@ -406,11 +406,17 @@ class SubscriptionService:
return None
# Resolve the Remnawave UUID: prefer subscription-level in multi-tariff mode
remnawave_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else user.remnawave_uuid
)
if settings.is_multi_tariff_enabled():
remnawave_uuid = subscription.remnawave_uuid
if not remnawave_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, cannot update panel',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
return None
else:
remnawave_uuid = user.remnawave_uuid
if not remnawave_uuid:
logger.error('RemnaWave UUID не найден для пользователя', user_id=subscription.user_id)
return None
@@ -489,17 +495,23 @@ class SubscriptionService:
updated_user = await api.update_user(**update_kwargs)
if reset_traffic:
reset_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else user.remnawave_uuid
)
await self._reset_user_traffic(
api,
reset_uuid,
user,
reset_reason,
)
if settings.is_multi_tariff_enabled():
reset_uuid = subscription.remnawave_uuid
if not reset_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, skipping traffic reset',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
else:
reset_uuid = user.remnawave_uuid
if reset_uuid:
await self._reset_user_traffic(
api,
reset_uuid,
user,
reset_reason,
)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
@@ -609,12 +621,20 @@ class SubscriptionService:
async def revoke_subscription(self, db: AsyncSession, subscription: Subscription) -> str | None:
try:
user = await get_user_by_id(db, subscription.user_id)
revoke_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else user.remnawave_uuid
)
if not user or not revoke_uuid:
if not user:
return None
if settings.is_multi_tariff_enabled():
revoke_uuid = subscription.remnawave_uuid
if not revoke_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, cannot revoke',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
return None
else:
revoke_uuid = user.remnawave_uuid
if not revoke_uuid:
return None
async with self.get_api_client() as api:
@@ -645,12 +665,20 @@ class SubscriptionService:
async def sync_subscription_usage(self, db: AsyncSession, subscription: Subscription) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
sync_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else user.remnawave_uuid
)
if not user or not sync_uuid:
if not user:
return False
if settings.is_multi_tariff_enabled():
sync_uuid = subscription.remnawave_uuid
if not sync_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, cannot sync usage',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
return False
else:
sync_uuid = user.remnawave_uuid
if not sync_uuid:
return False
async with self.get_api_client() as api:
@@ -941,12 +969,20 @@ class SubscriptionService:
async with semaphore:
try:
user = users_map.get(sub.user_id)
remnawave_uuid = (
sub.remnawave_uuid
if settings.is_multi_tariff_enabled() and sub.remnawave_uuid
else (user.remnawave_uuid if user else None)
)
if not user or not remnawave_uuid:
if not user:
return False
if settings.is_multi_tariff_enabled():
remnawave_uuid = sub.remnawave_uuid
if not remnawave_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, skipping squad sync',
subscription_id=sub.id,
user_id=sub.user_id,
)
return False
else:
remnawave_uuid = user.remnawave_uuid
if not remnawave_uuid:
return False
current_time = datetime.now(UTC)
+1 -1
View File
@@ -755,7 +755,7 @@ class UserService:
logger.info(
'✅ RemnaWave подписка восстановлена при разблокировке',
subscription_id=sub.id,
remnawave_uuid=user.remnawave_uuid,
remnawave_uuid=sub.remnawave_uuid if settings.is_multi_tariff_enabled() else user.remnawave_uuid,
)
except Exception as e:
logger.error(