fix: use limited tag for mark exhausted traffic users

This commit is contained in:
3252a8
2026-05-03 22:46:12 +03:00
parent aa5a9496ed
commit 63784bc036
3 changed files with 119 additions and 52 deletions
+16 -14
View File
@@ -45,7 +45,7 @@ class TariffTrafficWorker:
await self.traffic_period_tick(session) await self.traffic_period_tick(session)
await session.commit() await session.commit()
async with self.session_factory() as session: async with self.session_factory() as session:
await self.throttle_recovery_tick(session) await self.legacy_throttle_recovery_tick(session)
await session.commit() await session.commit()
except Exception: except Exception:
logging.exception("TariffTrafficWorker tick failed") logging.exception("TariffTrafficWorker tick failed")
@@ -74,10 +74,13 @@ class TariffTrafficWorker:
continue continue
panel_data = await self.panel_service.get_user_by_uuid(sub.panel_user_uuid, log_response=False) or {} panel_data = await self.panel_service.get_user_by_uuid(sub.panel_user_uuid, log_response=False) or {}
used, limit, panel_strategy = self.subscription_service._extract_panel_traffic_details(panel_data) used, limit, panel_strategy = self.subscription_service._extract_panel_traffic_details(panel_data)
panel_status = str(panel_data.get("status") or "").upper()
if used is not None and used != sub.traffic_used_bytes: if used is not None and used != sub.traffic_used_bytes:
sub.traffic_used_bytes = used sub.traffic_used_bytes = used
if limit is not None and limit != sub.traffic_limit_bytes: if limit is not None and limit != sub.traffic_limit_bytes:
sub.traffic_limit_bytes = limit sub.traffic_limit_bytes = limit
if panel_status and panel_status != (sub.status_from_panel or "").upper():
sub.status_from_panel = panel_status
if tariff.billing_model == "period": if tariff.billing_model == "period":
await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy) await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy)
@@ -103,7 +106,6 @@ class TariffTrafficWorker:
payload = self.subscription_service._build_panel_update_payload( payload = self.subscription_service._build_panel_update_payload(
panel_user_uuid=sub.panel_user_uuid, panel_user_uuid=sub.panel_user_uuid,
expire_at=sub.end_date, expire_at=sub.end_date,
status="ACTIVE",
traffic_limit_bytes=traffic_limit_bytes, traffic_limit_bytes=traffic_limit_bytes,
traffic_limit_strategy="MONTH", traffic_limit_strategy="MONTH",
) )
@@ -166,21 +168,21 @@ class TariffTrafficWorker:
await self.bot.send_message(sub.user_id, text, reply_markup=markup) await self.bot.send_message(sub.user_id, text, reply_markup=markup)
except Exception: except Exception:
logging.exception("Failed to send traffic warning to user %s", sub.user_id) logging.exception("Failed to send traffic warning to user %s", sub.user_id)
if ratio >= 1.0: if ratio >= 1.0 and not sub.is_throttled:
await self._throttle(session, sub, tariff) logging.info(
"Tariff traffic limit reached for user %s subscription %s. "
async def _throttle(self, session: AsyncSession, sub: Subscription, tariff) -> None: "Leaving access control to Remnawave status handling.",
if sub.is_throttled: sub.user_id,
return
for squad_uuid in tariff.squad_uuids:
await self.panel_service.remove_users_from_internal_squad(squad_uuid, [sub.panel_user_uuid])
await subscription_dal.update_subscription(
session,
sub.subscription_id, sub.subscription_id,
{"is_throttled": True, "status_from_panel": "THROTTLED_BY_BOT"},
) )
async def throttle_recovery_tick(self, session: AsyncSession) -> None: async def legacy_throttle_recovery_tick(self, session: AsyncSession) -> None:
"""Recover subscriptions throttled by older bot versions.
Current Remnawave versions enforce exhausted user traffic limits by
switching the user status to LIMITED, so new ticks must not remove users
from Internal Squads.
"""
result = await session.execute( result = await session.execute(
select(Subscription).where( select(Subscription).where(
Subscription.is_active == True, Subscription.is_active == True,
+53 -36
View File
@@ -1,21 +1,23 @@
# Тарифы 2.0 # Тарифы 2.0
Бот поддерживает каталог тарифов в JSON-файле. Путь задается через `TARIFFS_CONFIG_PATH`, по умолчанию `config/tariffs.json`. Бот поддерживает каталог тарифов в JSON-файле. Путь задается через `TARIFFS_CONFIG_PATH`, по умолчанию используется `config/tariffs.json`.
Если файл отсутствует, включается legacy fallback: используются старые `.env` поля `RUB_PRICE_*`, `STARS_PRICE_*`, `USER_TRAFFIC_LIMIT_GB`, `USER_SQUAD_UUIDS`, а также старый режим `TRAFFIC_PACKAGES`. Если файл отсутствует, включается legacy-режим: используются старые `.env` поля `RUB_PRICE_*`, `STARS_PRICE_*`, `USER_TRAFFIC_LIMIT_GB`, `USER_SQUAD_UUIDS`, а также старый режим продажи трафика через `TRAFFIC_PACKAGES`.
## Конфиг ## Конфиг
См. пример: [`config/tariffs.example.json`](../config/tariffs.example.json). Пример конфига: [`config/tariffs.example.json`](../config/tariffs.example.json).
Основные поля: Основные поля:
| Поле | Описание | | Поле | Описание |
| --- | --- | | --- | --- |
| `default_tariff` | Тариф для миграции существующих подписок и выбора по умолчанию | | `default_tariff` | Тариф по умолчанию для миграции существующих подписок и первичного выбора |
| `topup_packages_default` | Пакеты докупки для period-тарифов без собственных пакетов | | `topup_packages_default` | Пакеты докупки трафика для period-тарифов без собственных пакетов |
| `tariffs[].billing_model` | `period` или `traffic` | | `tariffs[].billing_model` | Модель тарификации: `period` или `traffic` |
| `tariffs[].squad_uuids` | Internal squads Remnawave для тарифа | | `tariffs[].squad_uuids` | Internal Squads Remnawave, которые получает пользователь на этом тарифе |
| `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств для тарифа; `0` означает без ограничений, отсутствие поля использует `USER_HWID_DEVICE_LIMIT` |
| `tariffs[].hwid_device_packages` | Пакеты докупки HWID-устройств, например `{ "count": 1, "price": 99 }` |
| `prices_rub` / `prices_stars` | Цены period-тарифов по месяцам | | `prices_rub` / `prices_stars` | Цены period-тарифов по месяцам |
| `traffic_packages` | Пакеты GB для traffic-тарифов | | `traffic_packages` | Пакеты GB для traffic-тарифов |
@@ -24,12 +26,12 @@
Period-тариф продает доступ на срок и лимит трафика с календарным ежемесячным сбросом. Period-тариф продает доступ на срок и лимит трафика с календарным ежемесячным сбросом.
- `monthly_gb` превращается в `tier_baseline_bytes`. - `monthly_gb` превращается в `tier_baseline_bytes`.
- Докупленные пакеты хранятся в `topup_balance_bytes`. - Докупленные пакеты трафика хранятся в `topup_balance_bytes`.
- В Remnawave пушится `trafficLimitBytes = tier_baseline_bytes + topup_balance_bytes`. - В Remnawave отправляется `trafficLimitBytes = tier_baseline_bytes + topup_balance_bytes`.
- Для period-тарифов бот выставляет `trafficLimitStrategy = MONTH`, а дальнейший reset делает сама панель. - Для period-тарифов бот выставляет `trafficLimitStrategy = MONTH`, а дальнейший сброс выполняет сама панель.
- Дата сброса больше не считается в боте. - Дата сброса больше не считается в боте.
- Если покупка или продление были в середине месяца, сброс всё равно произойдёт по правилам панели для `MONTH`. - Если покупка или продление были в середине месяца, сброс все равно произойдет по правилам панели для `MONTH`.
- Бот только меняет лимиты в GB и следит за предупреждениями/throttle на основе текущего usage из панели. - Бот меняет только лимиты в GB и следит за предупреждениями на основе текущего usage из панели.
## Traffic-Тариф ## Traffic-Тариф
@@ -41,42 +43,57 @@ Traffic-тариф продает GB без срока действия.
- Новая покупка добавляет GB к фактическому остатку: `limit = used + remaining + purchased`. - Новая покупка добавляет GB к фактическому остатку: `limit = used + remaining + purchased`.
- Доступ ограничивается только при исчерпании купленного трафика. - Доступ ограничивается только при исчерпании купленного трафика.
## HWID-Устройства
Тарифы поддерживают лимит HWID-устройств и платную докупку устройств.
- При покупке или продлении тарифа бот отправляет в Remnawave `hwidDeviceLimit`.
- `subscriptions.hwid_device_limit` хранит базовый лимит тарифа.
- `subscriptions.extra_hwid_devices` хранит количество докупленных устройств.
- Эффективный лимит равен `hwid_device_limit + extra_hwid_devices`.
- Если базовый лимит равен `0`, это безлимит; докупка не нужна, а в панель отправляется `0`.
- Докупка устройств использует `sale_mode=hwid_devices`.
- Количество купленных устройств сохраняется в `payments.purchased_hwid_devices`.
- История докупок пишется в `hwid_device_purchases`.
- Докупка доступна в Web App через `/api/devices/topup-options` и `/api/payments`.
- Докупка доступна в Telegram-боте из раздела устройств.
- При смене тарифа базовый HWID-лимит берется из нового тарифа, а уже докупленные устройства сохраняются.
## Смена Тарифа ## Смена Тарифа
Смена пишется в `tariff_changes`. Смена тарифа пишется в `tariff_changes`.
- `period -> period`: расчет идет от `effective_monthly_price_rub`; пересчет дней использует `floor`. - `period -> period`: расчет идет от `effective_monthly_price_rub`; пересчет дней использует `floor`.
- `period -> traffic`: остаток оплаченных дней конвертируется в GB по `conversion_rate_rub_per_gb` или минимальной цене GB в RUB-пакетах. - `period -> traffic`: остаток оплаченных дней конвертируется в GB по `conversion_rate_rub_per_gb` или по минимальной цене GB в RUB-пакетах.
- `traffic -> period`: пользователь покупает период, а остаток GB сохраняется как топ-ап поверх нового тарифа. - `traffic -> period`: пользователь покупает период, а остаток GB сохраняется как top-up поверх нового тарифа.
- При смене тарифа обновляются Internal Squads, лимит трафика, стратегия сброса и базовый HWID-лимит.
## Платежи ## Платежи
Новые платежи сохраняют: Новые платежи сохраняют:
- `sale_mode`: `subscription`, `traffic_package`, `topup`, `tariff_upgrade`; - `sale_mode`: `subscription`, `traffic_package`, `topup`, `tariff_upgrade`, `hwid_devices`;
- `tariff_key`; - `tariff_key`;
- `purchased_gb` для GB-покупок. - `purchased_gb` для GB-покупок;
- `purchased_hwid_devices` для докупки HWID-устройств.
Legacy поле `subscription_duration_months` остается для совместимости. Legacy-поле `subscription_duration_months` остается для совместимости с существующими платежными обработчиками.
## Поведение При Исчерпании Трафика
Remnawave сама ограничивает пользователя при достижении `trafficLimitBytes`: панель переводит пользователя в статус `LIMITED`. Бот не должен удалять пользователя из Internal Squads при 100% использования трафика.
Текущее поведение воркера:
- синхронизирует из панели `status`, `trafficLimitBytes`, `usedTrafficBytes` и `trafficLimitStrategy`;
- отправляет предупреждения на уровнях из `TARIFF_TRAFFIC_WARNING_LEVELS` (по умолчанию `85,90,95`);
- оставляет блокировку и разблокировку доступа штатной логике Remnawave.
## Воркеры ## Воркеры
`TariffTrafficWorker` запускается, только если активен `tariffs.json`. `TariffTrafficWorker` запускается только при активном `tariffs.json`.
- Раз в несколько минут синхронизирует `trafficLimitStrategy = MONTH` для period-тарифов, если панель ещё не переключена. - Раз в несколько минут синхронизирует `trafficLimitStrategy = MONTH` для period-тарифов, если панель еще не переключена.
- Отправляет/дедуплицирует уровни предупреждений из `TARIFF_TRAFFIC_WARNING_LEVELS` (по умолчанию `85,90,95`) через `traffic_warnings`. - При синхронизации стратегии не отправляет `status=ACTIVE`, чтобы случайно не снять статус `LIMITED`, выставленный Remnawave.
- При 100% удаляет пользователя из squad-ов тарифа и ставит `is_throttled`. - Дедуплицирует предупреждения через таблицу `traffic_warnings`.
- Возвращает пользователя в squad-ы, когда лимит снова больше использованного трафика. - Для traffic-тарифов дедупликация предупреждений учитывает текущий `trafficLimitBytes`, чтобы новая покупка трафика могла создать новый набор предупреждений.
## HWID devices
Tariff config now supports per-tariff device limits and paid device add-ons:
- `tariffs[].hwid_device_limit`: base HWID device limit for the tariff. `0` means unlimited. Missing/null falls back to `USER_HWID_DEVICE_LIMIT`.
- `tariffs[].hwid_device_packages`: paid add-on packages, for example `{ "count": 1, "price": 99 }` in `rub` or `stars`.
- The bot stores the tariff base limit in `subscriptions.hwid_device_limit` and purchased add-ons in `subscriptions.extra_hwid_devices`.
- Effective panel value is `hwid_device_limit + extra_hwid_devices`; if the base limit is `0`, it remains unlimited and add-ons are ignored.
- Device add-ons use `sale_mode=hwid_devices`, persist `payments.purchased_hwid_devices`, and update Remnawave via `hwidDeviceLimit` after payment.
- Add-ons are available in Web App via `/api/devices/topup-options` + `/api/payments`, and in Telegram via the devices section.
- On tariff change, the base HWID limit is taken from the new tariff, while already purchased extra devices are preserved.
+49 -1
View File
@@ -4,7 +4,7 @@ import unittest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, patch
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
@@ -76,3 +76,51 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
update_payload = panel_service.update_user_details_on_panel.await_args.args[1] update_payload = panel_service.update_user_details_on_panel.await_args.args[1]
self.assertEqual(update_payload["trafficLimitStrategy"], "MONTH") self.assertEqual(update_payload["trafficLimitStrategy"], "MONTH")
self.assertEqual(update_payload["trafficLimitBytes"], sub.traffic_limit_bytes) self.assertEqual(update_payload["trafficLimitBytes"], sub.traffic_limit_bytes)
self.assertNotIn("status", update_payload)
async def test_limit_reached_does_not_remove_user_from_squad(self):
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "tariffs.json"
config_path.write_text(json.dumps(_tariffs_config_payload()), encoding="utf-8")
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
TARIFFS_CONFIG_PATH=str(config_path),
TARIFF_TRAFFIC_WARNING_LEVELS="101",
)
panel_service = AsyncMock(spec=PanelApiService)
panel_service.remove_users_from_internal_squad = AsyncMock(return_value=True)
subscription_service = SubscriptionService(settings, panel_service)
worker = TariffTrafficWorker(
settings=settings,
session_factory=SimpleNamespace(),
panel_service=panel_service,
subscription_service=subscription_service,
)
sub = SimpleNamespace(
subscription_id=1,
user_id=123,
panel_user_uuid="panel-uuid",
traffic_limit_bytes=100,
traffic_used_bytes=100,
is_throttled=False,
status_from_panel="ACTIVE",
)
tariff = settings.tariffs_config.require("standard")
with patch("bot.services.tariff_worker.tariff_dal.get_warning", new=AsyncMock(return_value=True)):
await worker._maybe_warn_or_throttle(
AsyncMock(),
sub,
tariff,
used=100,
limit=100,
warning_period_start=datetime.now(timezone.utc),
)
panel_service.remove_users_from_internal_squad.assert_not_awaited()
self.assertFalse(sub.is_throttled)