This commit is contained in:
gy9vin
2026-01-26 21:06:59 +03:00
16 changed files with 210 additions and 20 deletions

27
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Lint
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: uv sync --group dev
- name: Check formatting
run: uv run ruff format --check .
- name: Check linting
run: uv run ruff check .

7
.gitignore vendored
View File

@@ -31,6 +31,13 @@ docker-compose.override.yml
# Разрешаем .gitignore чтобы он попал в репозиторий
!.gitignore
# Разрешаем .github/ (workflows, pre-commit и т.д.)
!.github/
!.github/**
# Разрешаем Makefile
!Makefile
# Внутри разрешенных папок игнорируем служебные файлы
app/__pycache__/
app/**/__pycache__/

View File

@@ -45,7 +45,5 @@ help: ## Показать список доступных команд
@echo ""
@echo "📘 Команды Makefile:"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
sed -E 's/:.*?## /| /' | \
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@echo ""

View File

@@ -688,7 +688,7 @@ async def get_top_referrers(
referrer_ids = list(referrers_data.keys())
if referrer_ids:
users_query = await db.execute(
select(User.id, User.telegram_id, User.username, User.first_name, User.last_name).where(
select(User.id, User.telegram_id, User.username, User.first_name, User.last_name, User.email).where(
User.id.in_(referrer_ids)
)
)

View File

@@ -284,6 +284,19 @@ async def get_payment_methods():
)
)
# Tribute
if settings.TRIBUTE_ENABLED and settings.TRIBUTE_DONATE_LINK:
methods.append(
PaymentMethodResponse(
id='tribute',
name='Tribute',
description='Pay with bank card via Tribute',
min_amount_kopeks=10000,
max_amount_kopeks=10000000,
is_available=True,
)
)
return methods
@@ -715,6 +728,17 @@ async def create_topup(
detail='Failed to create FreeKassa payment',
)
elif request.payment_method == 'tribute':
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tribute payment method is unavailable',
)
user_identifier = user.telegram_id or user.id
payment_url = f'{settings.TRIBUTE_DONATE_LINK}&user_id={user_identifier}'
payment_id = f'tribute_{user_identifier}_{request.amount_kopeks}'
else:
# For other payment methods, redirect to bot
raise HTTPException(

View File

@@ -4140,7 +4140,7 @@ async def create_button_click_logs_table() -> bool:
CREATE TABLE button_click_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
button_id VARCHAR(100) NOT NULL,
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
callback_data VARCHAR(255) NULL,
clicked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
button_type VARCHAR(20) NULL,
@@ -4152,7 +4152,7 @@ async def create_button_click_logs_table() -> bool:
CREATE TABLE button_click_logs (
id SERIAL PRIMARY KEY,
button_id VARCHAR(100) NOT NULL,
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
callback_data VARCHAR(255) NULL,
clicked_at TIMESTAMP DEFAULT NOW(),
button_type VARCHAR(20) NULL,
@@ -4164,12 +4164,12 @@ async def create_button_click_logs_table() -> bool:
CREATE TABLE button_click_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
button_id VARCHAR(100) NOT NULL,
user_id BIGINT NULL,
user_id INTEGER NULL,
callback_data VARCHAR(255) NULL,
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
button_type VARCHAR(20) NULL,
button_text VARCHAR(255) NULL,
FOREIGN KEY (user_id) REFERENCES users(telegram_id) ON DELETE SET NULL
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB
"""
@@ -4194,6 +4194,74 @@ async def create_button_click_logs_table() -> bool:
return False
async def fix_button_click_logs_fk() -> bool:
"""Исправляет FK button_click_logs.user_id: users(telegram_id) -> users(id)."""
table_exists = await check_table_exists('button_click_logs')
if not table_exists:
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'postgresql':
# Проверяем, ссылается ли FK на telegram_id (ошибочный вариант)
check_sql = text("""
SELECT ccu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_name = 'button_click_logs'
AND tc.constraint_type = 'FOREIGN KEY'
AND ccu.table_name = 'users'
LIMIT 1
""")
result = await conn.execute(check_sql)
row = result.fetchone()
if row and row[0] == 'telegram_id':
logger.info('🔧 Исправляем FK button_click_logs.user_id: telegram_id -> id')
# Обнуляем невалидные user_id (которые были internal id, а не telegram_id)
await conn.execute(text("""
UPDATE button_click_logs
SET user_id = NULL
WHERE user_id IS NOT NULL
AND user_id NOT IN (SELECT telegram_id FROM users)
"""))
# Удаляем старый FK
await conn.execute(text(
'ALTER TABLE button_click_logs DROP CONSTRAINT IF EXISTS button_click_logs_user_id_fkey'
))
# Меняем тип колонки и добавляем правильный FK
await conn.execute(text(
'ALTER TABLE button_click_logs ALTER COLUMN user_id TYPE INTEGER'
))
# Обнуляем все значения, т.к. они были записаны неправильно
await conn.execute(text(
'UPDATE button_click_logs SET user_id = NULL'
))
await conn.execute(text(
'ALTER TABLE button_click_logs '
'ADD CONSTRAINT button_click_logs_user_id_fkey '
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL'
))
logger.info('✅ FK button_click_logs.user_id исправлен')
else:
logger.debug('FK button_click_logs.user_id уже корректен')
return True
except Exception as error:
logger.error(f'❌ Ошибка исправления FK button_click_logs: {error}')
return False
async def create_web_api_tokens_table() -> bool:
table_exists = await check_table_exists('web_api_tokens')
if table_exists:
@@ -6190,6 +6258,13 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с таблицей button_click_logs')
logger.info('=== ИСПРАВЛЕНИЕ FK BUTTON_CLICK_LOGS ===')
fk_fixed = await fix_button_click_logs_fk()
if fk_fixed:
logger.info('✅ FK button_click_logs проверен')
else:
logger.warning('⚠️ Проблемы с FK button_click_logs')
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===')
trial_column_ready = await add_server_trial_flag_column()
if trial_column_ready:

View File

@@ -341,7 +341,11 @@ class RemnaWaveAPI:
connector = aiohttp.TCPConnector(**connector_kwargs)
session_kwargs = {'timeout': aiohttp.ClientTimeout(total=60, connect=10), 'headers': headers, 'connector': connector}
session_kwargs = {
'timeout': aiohttp.ClientTimeout(total=60, connect=10),
'headers': headers,
'connector': connector,
}
if cookies:
session_kwargs['cookies'] = cookies

View File

@@ -1581,6 +1581,11 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
else:
device_limit = forced_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
device_limit = max(1, device_limit - 1)
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_total_base = devices_price_per_month * months_in_period
@@ -1805,6 +1810,11 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
else:
device_limit = forced_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
device_limit = max(1, device_limit - 1)
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(

View File

@@ -362,6 +362,10 @@ class HeleketPaymentMixin:
await db.commit()
await db.refresh(user)
# Перезагружаем пользователя с зависимостями после коммита,
# чтобы избежать lazy load в async-контексте (MissingGreenlet)
user = await get_user_by_id(db, user.id) or user
if getattr(self, 'bot', None):
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
referrer_info = format_referrer_info(user)

View File

@@ -383,6 +383,22 @@ class TelegramStarsMixin:
exc_info=True,
)
# Начисляем реферальную комиссию за прямую покупку подписки
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as ref_error:
logger.error(
'Ошибка реферального начисления при покупке подписки через Stars: %s',
ref_error,
)
logger.info(
'✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s',
user.id,

View File

@@ -472,7 +472,10 @@ class WataPaymentMixin:
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
user = await payment_module.get_user_by_id(db, user.id)
if not user:
logger.error('Пользователь %s не найден после коммита WATA', payment.user_id)
return payment
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)

View File

@@ -687,6 +687,22 @@ class YooKassaPaymentMixin:
payment.yookassa_payment_id,
user.id,
)
# Начисляем реферальную комиссию за прямую покупку подписки
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as ref_error:
logger.error(
'Ошибка реферального начисления при покупке подписки YooKassa: %s',
ref_error,
)
else:
old_balance = getattr(user, 'balance_kopeks', 0)
was_first_topup = not getattr(user, 'has_made_first_topup', False)

View File

@@ -79,11 +79,12 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.YOOKASSA,
PaymentMethod.MULENPAY,
PaymentMethod.PAL24,
PaymentMethod.WATA,
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
# CloudPayments removed - API returns "Completed" during authorization
# before final result, causing premature balance credits. Webhooks work correctly.
# WATA removed - API returns 429 "Use webhook polling is rate-limited".
# Payments are processed via webhook (wata_webhook.py).
PaymentMethod.FREEKASSA,
}
)

View File

@@ -1174,10 +1174,7 @@ class RemnaWaveService:
user.remnawave_uuid: user for user in bot_users if getattr(user, 'remnawave_uuid', None)
}
# Index users by email for email-only sync
bot_users_by_email = {
user.email.lower(): user for user in bot_users
if user.email and user.email_verified
}
bot_users_by_email = {user.email.lower(): user for user in bot_users if user.email and user.email_verified}
# Also index email-only users by their remnawave_uuid for sync
email_users_count = sum(1 for u in bot_users if u.telegram_id is None)
if email_users_count > 0:
@@ -1203,8 +1200,7 @@ class RemnaWaveService:
# Email-only пользователи из панели (без telegram_id, но с email)
panel_users_email_only = [
user for user in panel_users
if user.get('telegramId') is None and user.get('email')
user for user in panel_users if user.get('telegramId') is None and user.get('email')
]
if panel_users_email_only:
logger.info(f'📧 Пользователей в панели с Email (без Telegram): {len(panel_users_email_only)}')
@@ -1843,7 +1839,10 @@ class RemnaWaveService:
telegram_id=user.telegram_id,
email=user.email,
description=settings.format_remnawave_user_description(
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id, email=user.email
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
),
active_internal_squads=sub.connected_squads,
)

View File

@@ -192,6 +192,12 @@ class SubscriptionService:
logger.error(f'Ошибка валидации подписки для пользователя {self._format_user_log(user)}')
return None
# Загружаем tariff заранее, чтобы избежать lazy loading в async контексте
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass # tariff может быть None или уже загружен
user_tag = self._resolve_user_tag(subscription)
async with self.get_api_client() as api:

View File

@@ -553,8 +553,8 @@ class TrafficMonitoringServiceV2:
# Получаем период за последние 24 часа
now = datetime.utcnow()
start_date = (now - timedelta(hours=24)).strftime('%Y-%m-%dT%H:%M:%S.000Z')
end_date = now.strftime('%Y-%m-%dT%H:%M:%S.999Z')
start_date = (now - timedelta(hours=24)).strftime('%Y-%m-%d')
end_date = now.strftime('%Y-%m-%d')
users = await self.get_all_users_with_traffic()
semaphore = asyncio.Semaphore(self.get_concurrency())