fix: use limited tag for mark exhausted traffic users
This commit is contained in:
@@ -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. "
|
||||||
|
"Leaving access control to Remnawave status handling.",
|
||||||
|
sub.user_id,
|
||||||
|
sub.subscription_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def _throttle(self, session: AsyncSession, sub: Subscription, tariff) -> None:
|
async def legacy_throttle_recovery_tick(self, session: AsyncSession) -> None:
|
||||||
if sub.is_throttled:
|
"""Recover subscriptions throttled by older bot versions.
|
||||||
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,
|
|
||||||
{"is_throttled": True, "status_from_panel": "THROTTLED_BY_BOT"},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def throttle_recovery_tick(self, session: AsyncSession) -> None:
|
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
@@ -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.
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user