6 Commits
master ... V2

Author SHA1 Message Date
Андрей Цой
fb1830f77e Protect deliveries to blocked Telegram users 2026-07-02 17:49:05 +09:00
Андрей Цой
b0e42688b6 Shorten performance index migration revision 2026-07-02 06:17:22 +09:00
Андрей Цой
c7c60b70c8 Use raw SQL for performance index migration 2026-07-01 21:24:17 +09:00
Андрей Цой
26c65b7caa Harden bot against runtime stalls 2026-07-01 20:35:50 +09:00
Андрей Цой
6fbe4c4f43 Use external database in compose by default 2026-07-01 18:30:39 +09:00
Андрей Цой
3906398136 Modernize bot for Python 3.12 2026-07-01 18:24:16 +09:00
37 changed files with 1348 additions and 702 deletions

View File

@@ -9,6 +9,15 @@ BOT_TOKEN=your_bot_token_here
# Для PostgreSQL (рекомендуется для продакшена): # Для PostgreSQL (рекомендуется для продакшена):
DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot
DB_CONNECT_TIMEOUT=5
DB_POOL_SIZE=5
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=10
DB_POOL_RECYCLE=1800
# === REDIS ===
REDIS_URL=redis://localhost:6379/0
USE_REDIS_STORAGE=true
# === АДМИНИСТРАТОРЫ === # === АДМИНИСТРАТОРЫ ===
# ID администраторов Telegram (через запятую) # ID администраторов Telegram (через запятую)
@@ -19,6 +28,12 @@ ADMIN_IDS=123456789,987654321
# Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL # Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Логировать все SQL-запросы SQLAlchemy (обычно нужно только при отладке)
SQL_ECHO=false
# Обновлять last_activity не чаще указанного интервала на пользователя
ACTIVITY_UPDATE_INTERVAL_SECONDS=300
# === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) === # === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) ===
# Максимальное количество участников в одном розыгрыше # Максимальное количество участников в одном розыгрыше
# MAX_PARTICIPANTS_PER_LOTTERY=10000 # MAX_PARTICIPANTS_PER_LOTTERY=10000

View File

@@ -1,7 +1,10 @@
# Makefile для телеграм-бота розыгрышей # Makefile для телеграм-бота розыгрышей
# Определяем команду $(DOCKER_COMPOSE) (v2) или docker compose (v1) # Python runtime for V2. Override if needed: make PYTHON=/path/to/python3.12 install
DOCKER_COMPOSE := $(shell command -v $(DOCKER_COMPOSE) 2> /dev/null || command -v docker compose 2> /dev/null) PYTHON ?= python3.12
# Определяем команду Docker Compose: предпочтительно v2 (`docker compose`), fallback v1 (`docker-compose`)
DOCKER_COMPOSE ?= $(shell if docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
.PHONY: help install setup setup-postgres init-db run test clean .PHONY: help install setup setup-postgres init-db run test clean
@@ -36,7 +39,7 @@ help:
# Установка зависимостей # Установка зависимостей
install: install:
@echo "📦 Установка зависимостей..." @echo "📦 Установка зависимостей..."
python3 -m venv .venv $(PYTHON) -m venv .venv
. .venv/bin/activate && pip install -r requirements.txt . .venv/bin/activate && pip install -r requirements.txt
# Настройка PostgreSQL базы данных # Настройка PostgreSQL базы данных
@@ -217,7 +220,7 @@ docker-setup:
# Проверка Docker и Docker Compose # Проверка Docker и Docker Compose
docker-check: docker-check:
@echo "<EFBFBD> Проверка Docker окружения..." @echo "🔍 Проверка Docker окружения..."
@command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; } @command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; }
@echo "✅ Docker: $$(docker --version)" @echo "✅ Docker: $$(docker --version)"
@if [ -z "$(DOCKER_COMPOSE)" ]; then \ @if [ -z "$(DOCKER_COMPOSE)" ]; then \

View File

@@ -7,16 +7,18 @@ services:
image: postgres:15-alpine image: postgres:15-alpine
container_name: lottery_postgres container_name: lottery_postgres
restart: unless-stopped restart: unless-stopped
profiles:
- local-db
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr} POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
POSTGRES_USER: ${POSTGRES_USER:-trevor} POSTGRES_USER: ${POSTGRES_USER:-lottery}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-Cl0ud_1985!} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong_password}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: networks:
- lottery_network - lottery_network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trevor}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -45,18 +47,23 @@ services:
container_name: lottery_bot container_name: lottery_bot
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env.prod - .env
environment: environment:
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
- USE_REDIS_STORAGE=${USE_REDIS_STORAGE:-true}
- DB_CONNECT_TIMEOUT=${DB_CONNECT_TIMEOUT:-5}
- DB_POOL_SIZE=${DB_POOL_SIZE:-5}
- DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-10}
- DB_POOL_TIMEOUT=${DB_POOL_TIMEOUT:-10}
- DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-1800}
- ACTIVITY_UPDATE_INTERVAL_SECONDS=${ACTIVITY_UPDATE_INTERVAL_SECONDS:-300}
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
- bot_data:/app/data - bot_data:/app/data
networks: networks:
- lottery_network - lottery_network
depends_on: depends_on:
postgres:
condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:

35
main.py
View File

@@ -10,11 +10,12 @@ from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from src.filters.case_insensitive import CaseInsensitiveCommand from src.filters.case_insensitive import CaseInsensitiveCommand
from src.core.config import BOT_TOKEN from src.core.config import BOT_TOKEN, LOG_LEVEL, REDIS_URL, USE_REDIS_STORAGE
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.scheduler import bot_scheduler from src.core.scheduler import bot_scheduler
from src.container import container from src.container import container
@@ -31,17 +32,33 @@ from src.handlers.message_management import message_admin_router
from src.handlers.p2p_chat import router as p2p_chat_router from src.handlers.p2p_chat import router as p2p_chat_router
from src.handlers.help_handlers import router as help_router from src.handlers.help_handlers import router as help_router
from src.handlers.admin_emoji_handlers import router as admin_emoji_router from src.handlers.admin_emoji_handlers import router as admin_emoji_router
from src.utils.task_manager import task_manager
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Создание бота и диспетчера # Создание бота и диспетчера
bot = Bot(token=BOT_TOKEN) bot = Bot(token=BOT_TOKEN)
storage = MemoryStorage()
def create_fsm_storage():
if not USE_REDIS_STORAGE:
logger.warning("FSM storage: используется MemoryStorage")
return MemoryStorage()
try:
logger.info("FSM storage: используется RedisStorage")
return RedisStorage.from_url(REDIS_URL)
except Exception as e:
logger.error(f"Не удалось создать RedisStorage, используется MemoryStorage: {e}")
return MemoryStorage()
storage = create_fsm_storage()
dp = Dispatcher(storage=storage) dp = Dispatcher(storage=storage)
router = Router() router = Router()
@@ -50,9 +67,9 @@ router = Router()
@dp.callback_query.middleware() @dp.callback_query.middleware()
async def log_callback_middleware(handler, event, data): async def log_callback_middleware(handler, event, data):
"""Middleware для логирования всех callback запросов""" """Middleware для логирования всех callback запросов"""
logger.warning(f"🔔 MIDDLEWARE CALLBACK: data='{event.data}', user_id={event.from_user.id}") logger.debug(f"Callback received: data={event.data!r}, user_id={event.from_user.id}")
result = await handler(event, data) result = await handler(event, data)
logger.warning(f"🔔 MIDDLEWARE CALLBACK HANDLED: data='{event.data}', result={result}") logger.debug(f"Callback handled: data={event.data!r}, result={result}")
return result return result
@@ -179,7 +196,7 @@ async def btn_my_code(message: Message):
await show_verification_code(message) await show_verification_code(message)
@router.message(F.text == "<EFBFBD> Мои логины") @router.message(F.text == "📱 Мои логины")
async def btn_my_accounts(message: Message): async def btn_my_accounts(message: Message):
"""Обработчик кнопки 'Мои логины'""" """Обработчик кнопки 'Мои логины'"""
from src.handlers.registration_handlers import show_user_accounts from src.handlers.registration_handlers import show_user_accounts
@@ -309,6 +326,9 @@ async def main():
bot_scheduler.start() bot_scheduler.start()
logger.info("Планировщик задач запущен") logger.info("Планировщик задач запущен")
await task_manager.start()
logger.info("Менеджер фоновых задач запущен")
# Запускаем polling # Запускаем polling
try: try:
logger.info("Бот запущен") logger.info("Бот запущен")
@@ -318,6 +338,9 @@ async def main():
finally: finally:
# Останавливаем планировщик # Останавливаем планировщик
bot_scheduler.shutdown() bot_scheduler.shutdown()
await task_manager.stop()
if hasattr(storage, "close"):
await storage.close()
await bot.session.close() await bot.session.close()

View File

@@ -17,72 +17,10 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # Duplicate autogenerated migration. The real changes are applied by
op.create_table('blocked_users', # 71376bb89294, 1f1631301809 and b4c435a7dc5f.
sa.Column('id', sa.Integer(), nullable=False), pass
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
sa.Column('error_type', sa.String(length=100), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('first_blocked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('attempt_count', sa.Integer(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_blocked_users_is_active'), 'blocked_users', ['is_active'], unique=False)
op.create_index(op.f('ix_blocked_users_telegram_id'), 'blocked_users', ['telegram_id'], unique=True)
op.create_table('broadcast_channels',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chat_id', sa.BigInteger(), nullable=False),
sa.Column('chat_type', sa.String(length=20), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('username', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('added_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['added_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_broadcast_channels_chat_id'), 'broadcast_channels', ['chat_id'], unique=True)
op.create_index(op.f('ix_broadcast_channels_is_active'), 'broadcast_channels', ['is_active'], unique=False)
op.create_table('broadcast_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('broadcast_type', sa.String(length=20), nullable=False),
sa.Column('target_id', sa.BigInteger(), nullable=True),
sa.Column('message_type', sa.String(length=20), nullable=False),
sa.Column('message_text', sa.Text(), nullable=True),
sa.Column('file_id', sa.String(length=255), nullable=True),
sa.Column('total_recipients', sa.Integer(), nullable=True),
sa.Column('success_count', sa.Integer(), nullable=True),
sa.Column('failed_count', sa.Integer(), nullable=True),
sa.Column('blocked_count', sa.Integer(), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_broadcast_logs_broadcast_type'), 'broadcast_logs', ['broadcast_type'], unique=False)
op.create_index(op.f('ix_broadcast_logs_status'), 'broadcast_logs', ['status'], unique=False)
op.add_column('users', sa.Column('is_chat_banned', sa.Boolean(), nullable=True))
op.add_column('users', sa.Column('last_activity', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### pass
op.drop_column('users', 'last_activity')
op.drop_column('users', 'is_chat_banned')
op.drop_index(op.f('ix_broadcast_logs_status'), table_name='broadcast_logs')
op.drop_index(op.f('ix_broadcast_logs_broadcast_type'), table_name='broadcast_logs')
op.drop_table('broadcast_logs')
op.drop_index(op.f('ix_broadcast_channels_is_active'), table_name='broadcast_channels')
op.drop_index(op.f('ix_broadcast_channels_chat_id'), table_name='broadcast_channels')
op.drop_table('broadcast_channels')
op.drop_index(op.f('ix_blocked_users_telegram_id'), table_name='blocked_users')
op.drop_index(op.f('ix_blocked_users_is_active'), table_name='blocked_users')
op.drop_table('blocked_users')
# ### end Alembic commands ###

View File

@@ -1,7 +1,7 @@
"""merge branches """merge branches
Revision ID: merge_migration Revision ID: merge_migration
Revises: cd31303a681c Revises: cd31303a681c, 41aae82e631b
Create Date: 2026-02-18 04:02:12.000000 Create Date: 2026-02-18 04:02:12.000000
""" """
@@ -11,7 +11,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'merge_migration' revision = 'merge_migration'
down_revision = 'cd31303a681c' down_revision = ('cd31303a681c', '41aae82e631b')
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View File

@@ -0,0 +1,50 @@
"""add performance indexes
Revision ID: 20260701_perf_indexes
Revises: 20260307_0100_add_emoji_mappings
Create Date: 2026-07-01 00:01:00.000000
"""
from alembic import op
revision = '20260701_perf_indexes'
down_revision = '20260307_0100_add_emoji_mappings'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS ix_users_registered_activity "
"ON users (is_registered, last_activity)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_participations_lottery_account "
"ON participations (lottery_id, account_number)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_participations_user_created "
"ON participations (user_id, created_at)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_winners_lottery_place "
"ON winners (lottery_id, place)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_winners_user_id "
"ON winners (user_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_blocked_users_lookup "
"ON blocked_users (telegram_id, error_type, is_active)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_blocked_users_lookup")
op.execute("DROP INDEX IF EXISTS ix_winners_user_id")
op.execute("DROP INDEX IF EXISTS ix_winners_lottery_place")
op.execute("DROP INDEX IF EXISTS ix_participations_user_created")
op.execute("DROP INDEX IF EXISTS ix_participations_lottery_account")
op.execute("DROP INDEX IF EXISTS ix_users_registered_activity")

27
pyproject.toml Normal file
View File

@@ -0,0 +1,27 @@
[project]
name = "new-lottery-bot"
version = "2.0.0"
description = "Telegram lottery bot"
requires-python = ">=3.12"
dependencies = [
"aiogram==3.29.0",
"aiohttp==3.14.1",
"SQLAlchemy==2.0.51",
"alembic==1.18.5",
"python-dotenv==1.2.2",
"asyncpg==0.31.0",
"aiosqlite==0.22.1",
"redis==8.0.1",
"APScheduler==3.11.3",
"openpyxl==3.1.5",
]
[project.optional-dependencies]
dev = [
"pytest==9.1.1",
"pytest-asyncio==1.4.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View File

@@ -1,12 +1,15 @@
# Updated for Python 3.12 compatibility # Runtime target: Python 3.12+
aiogram==3.16.0 aiogram==3.29.0
aiohttp>=3.11.0 aiohttp==3.14.1
sqlalchemy==2.0.36 SQLAlchemy==2.0.51
alembic==1.14.0 alembic==1.18.5
python-dotenv==1.0.1 python-dotenv==1.2.2
asyncpg==0.30.0 asyncpg==0.31.0
aiosqlite==0.20.0 aiosqlite==0.22.1
redis==5.2.1 redis==8.0.1
aioredis==2.0.1 APScheduler==3.11.3
apscheduler==3.10.4 openpyxl==3.1.5
openpyxl==3.1.2
# Development / diagnostics
pytest==9.1.1
pytest-asyncio==1.4.0

View File

@@ -3,12 +3,10 @@
Скрипт для очистки и инициализации БД с тестовыми данными Скрипт для очистки и инициализации БД с тестовыми данными
""" """
import asyncio import asyncio
import random from src.core.database import async_session_maker
from database import async_session_maker, Base, engine from src.core.models import User, Lottery, Participation, Winner, Account
from models import User, Lottery, Participation, Winner from src.core.services import LotteryService, UserService, ParticipationService
from services import LotteryService, UserService, ParticipationService from sqlalchemy import delete, select, func
from datetime import datetime, timedelta
from sqlalchemy import delete, update, select, func
async def clear_database(): async def clear_database():
"""Очистка всех таблиц""" """Очистка всех таблиц"""
@@ -18,6 +16,7 @@ async def clear_database():
# Удаляем все данные в правильном порядке (учитывая внешние ключи) # Удаляем все данные в правильном порядке (учитывая внешние ключи)
await session.execute(delete(Winner)) await session.execute(delete(Winner))
await session.execute(delete(Participation)) await session.execute(delete(Participation))
await session.execute(delete(Account))
await session.execute(delete(Lottery)) await session.execute(delete(Lottery))
await session.execute(delete(User)) await session.execute(delete(User))
await session.commit() await session.commit()
@@ -26,8 +25,10 @@ async def clear_database():
def generate_account_numbers(user_id: int, count: int = 10) -> list: def generate_account_numbers(user_id: int, count: int = 10) -> list:
"""Генерация номеров счетов для пользователя""" """Генерация номеров счетов для пользователя"""
base = user_id * 1000 return [
return [f"{base + i:06d}" for i in range(1, count + 1)] "-".join(f"{part:02d}" for part in [user_id, i, 11, 22, 33, 44, 55])
for i in range(1, count + 1)
]
async def create_test_users(): async def create_test_users():
"""Создание тестовых пользователей""" """Создание тестовых пользователей"""
@@ -62,12 +63,8 @@ async def create_test_users():
# Генерируем и присваиваем номера счетов # Генерируем и присваиваем номера счетов
account_numbers = generate_account_numbers(i, 10) account_numbers = generate_account_numbers(i, 10)
# Обновляем счета for account_number in account_numbers:
await session.execute( await UserService.set_account_number(session, user.telegram_id, account_number)
update(User)
.where(User.id == user.id)
.values(account_number=",".join(account_numbers))
)
await session.commit() await session.commit()
created_users.append((user, account_numbers)) created_users.append((user, account_numbers))

View File

@@ -2,8 +2,8 @@
Примеры использования API сервисов для тестирования Примеры использования API сервисов для тестирования
""" """
import asyncio import asyncio
from database import async_session_maker, init_db from src.core.database import async_session_maker, init_db
from services import UserService, LotteryService, ParticipationService from src.core.services import UserService, LotteryService, ParticipationService
async def example_usage(): async def example_usage():
@@ -61,7 +61,7 @@ async def example_usage():
# Добавляем участников # Добавляем участников
for user in users: for user in users:
success = await LotteryService.add_participant(session, lottery.id, user.id) success = await ParticipationService.add_participant(session, lottery.id, user.id)
if success: if success:
print(f"{user.first_name} присоединился к розыгрышу") print(f"{user.first_name} присоединился к розыгрышу")
@@ -93,6 +93,7 @@ async def example_usage():
# Проводим розыгрыш # Проводим розыгрыш
results = await LotteryService.conduct_draw(session, lottery.id) results = await LotteryService.conduct_draw(session, lottery.id)
await session.commit()
print(f"\n🏆 Результаты розыгрыша:") print(f"\n🏆 Результаты розыгрыша:")
print(f"🎯 Розыгрыш: {lottery.title}") print(f"🎯 Розыгрыш: {lottery.title}")
@@ -134,7 +135,7 @@ async def test_edge_cases():
user = await UserService.get_user_by_telegram_id(session, 100000001) user = await UserService.get_user_by_telegram_id(session, 100000001)
lottery = (await LotteryService.get_active_lotteries(session))[0] lottery = (await LotteryService.get_active_lotteries(session))[0]
success = await LotteryService.add_participant(session, lottery.id, user.id) success = await ParticipationService.add_participant(session, lottery.id, user.id)
print(f"Повторное добавление участника: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}") print(f"Повторное добавление участника: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}")
# Попытка установить ручного победителя для несуществующего пользователя # Попытка установить ручного победителя для несуществующего пользователя

View File

@@ -72,13 +72,9 @@ class MessageFormatterImpl(IMessageFormatter):
text = text[:47] + "..." text = text[:47] + "..."
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")]) buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")])
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="lottery_management")]) buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_lotteries")])
return InlineKeyboardMarkup(inline_keyboard=buttons) return InlineKeyboardMarkup(inline_keyboard=buttons)
class MessageFormatterImpl(IMessageFormatter):
"""Реализация форматирования сообщений"""
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str: def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str:
"""Форматировать информацию о розыгрыше""" """Форматировать информацию о розыгрыше"""
text = f"🎲 **{lottery.title}**\n\n" text = f"🎲 **{lottery.title}**\n\n"

View File

@@ -5,7 +5,7 @@
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from sqlalchemy import select, and_, update from sqlalchemy import select, and_, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import List from typing import List, Optional
import logging import logging
from .models import User, BlockedUser from .models import User, BlockedUser
@@ -41,6 +41,34 @@ class ActivityService:
logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}") logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}")
await session.rollback() await session.rollback()
@staticmethod
async def update_activity_and_reactivate(session: AsyncSession, telegram_id: int) -> Optional[bool]:
"""Обновить активность и снять блокировку за неактивность одним коммитом."""
try:
now = datetime.now(timezone.utc)
await session.execute(
update(User)
.where(User.telegram_id == telegram_id)
.values(last_activity=now)
)
result = await session.execute(
update(BlockedUser)
.where(
and_(
BlockedUser.telegram_id == telegram_id,
BlockedUser.is_active == True,
)
)
.values(is_active=False, last_attempt_at=now)
)
await session.commit()
return result.rowcount > 0
except Exception as e:
logger.error(f"Ошибка обновления активности/реактивации пользователя {telegram_id}: {e}")
await session.rollback()
return None
@staticmethod @staticmethod
async def get_inactive_users( async def get_inactive_users(
session: AsyncSession, session: AsyncSession,
@@ -84,35 +112,45 @@ class ActivityService:
Количество помеченных пользователей Количество помеченных пользователей
""" """
try: try:
inactive_users = await ActivityService.get_inactive_users(session, days) if days is None:
marked_count = 0 days = ActivityService.INACTIVITY_PERIOD_DAYS
for user in inactive_users: cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
# Проверяем, не помечен ли уже already_blocked = (
stmt = select(BlockedUser).where( select(BlockedUser.id)
.where(
and_( and_(
BlockedUser.telegram_id == user.telegram_id, BlockedUser.telegram_id == User.telegram_id,
BlockedUser.error_type == 'inactive', BlockedUser.error_type == 'inactive',
BlockedUser.is_active == True BlockedUser.is_active == True,
) )
) )
result = await session.execute(stmt) .exists()
existing = result.scalar_one_or_none() )
result = await session.execute(
if not existing: select(User).where(
# Создаем новую запись and_(
blocked = BlockedUser( User.last_activity < cutoff_date,
telegram_id=user.telegram_id, User.is_registered == True,
error_type='inactive', ~already_blocked,
error_message=f'User inactive for {days} days',
first_blocked_at=datetime.now(timezone.utc),
last_attempt_at=datetime.now(timezone.utc),
attempt_count=1,
is_active=True
) )
session.add(blocked) )
marked_count += 1 )
logger.info(f"Пользователь {user.telegram_id} помечен как неактивный (последняя активность: {user.last_activity})") inactive_users = list(result.scalars().all())
marked_count = 0
now = datetime.now(timezone.utc)
for user in inactive_users:
session.add(BlockedUser(
telegram_id=user.telegram_id,
error_type='inactive',
error_message=f'User inactive for {days} days',
first_blocked_at=now,
last_attempt_at=now,
attempt_count=1,
is_active=True
))
marked_count += 1
await session.commit() await session.commit()
return marked_count return marked_count

View File

@@ -8,7 +8,6 @@ from typing import Optional, List, Dict, Tuple, Any
from datetime import datetime, timezone from datetime import datetime, timezone
from aiogram import Bot from aiogram import Bot
from aiogram.types import Message from aiogram.types import Message
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as redis import redis.asyncio as redis
@@ -16,6 +15,7 @@ import redis.asyncio as redis
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
from .config import REDIS_URL, ADMIN_IDS from .config import REDIS_URL, ADMIN_IDS
from .database import async_session_maker from .database import async_session_maker
from .delivery import delivery_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -93,6 +93,7 @@ class BroadcastService:
BATCH_SIZE = 30 # Сообщений в пакете BATCH_SIZE = 30 # Сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды) BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
MAX_RETRY_AFTER = 30
def __init__(self): def __init__(self):
self.redis_queue = RedisQueue() self.redis_queue = RedisQueue()
@@ -131,29 +132,9 @@ class BroadcastService:
error_type: Тип ошибки error_type: Тип ошибки
error_message: Сообщение об ошибке error_message: Сообщение об ошибке
""" """
# Проверяем, есть ли уже запись await delivery_service.mark_user_blocked(
stmt = select(BlockedUser).where(BlockedUser.telegram_id == telegram_id) session, telegram_id, error_type, error_message
result = await session.execute(stmt) )
blocked_user = result.scalar_one_or_none()
if blocked_user:
# Обновляем существующую запись
blocked_user.error_type = error_type
blocked_user.error_message = error_message
blocked_user.last_attempt_at = datetime.now(timezone.utc)
blocked_user.attempt_count += 1
blocked_user.is_active = True
else:
# Создаем новую запись
blocked_user = BlockedUser(
telegram_id=telegram_id,
error_type=error_type,
error_message=error_message
)
session.add(blocked_user)
await session.commit()
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
async def unblock_user(self, session: AsyncSession, telegram_id: int): async def unblock_user(self, session: AsyncSession, telegram_id: int):
""" """
@@ -163,23 +144,15 @@ class BroadcastService:
session: Сессия БД session: Сессия БД
telegram_id: Telegram ID пользователя telegram_id: Telegram ID пользователя
""" """
stmt = select(BlockedUser).where( await delivery_service.unblock_user(session, telegram_id)
BlockedUser.telegram_id == telegram_id,
BlockedUser.is_active == True
)
result = await session.execute(stmt)
blocked_user = result.scalar_one_or_none()
if blocked_user:
blocked_user.is_active = False
await session.commit()
logger.info(f"Пользователь {telegram_id} разблокирован")
async def send_message_to_user( async def send_message_to_user(
self, self,
bot: Bot, bot: Bot,
user: User, user: User,
message: Message message: Message,
retry: bool = True,
skip_block_check: bool = False,
) -> Tuple[bool, Optional[str]]: ) -> Tuple[bool, Optional[str]]:
""" """
Отправить сообщение пользователю с обработкой ошибок Отправить сообщение пользователю с обработкой ошибок
@@ -192,86 +165,43 @@ class BroadcastService:
Returns: Returns:
Tuple[bool, Optional[str]]: (успех, тип_ошибки) Tuple[bool, Optional[str]]: (успех, тип_ошибки)
""" """
try: if message.text:
# Проверяем, не заблокирован ли пользователь send_call = lambda: bot.send_message(
async with async_session_maker() as session:
is_blocked = await self.check_user_blocked(session, user.telegram_id)
if is_blocked:
logger.debug(f"Пропускаем заблокированного пользователя {user.telegram_id}")
return False, "blocked"
# Отправляем сообщение
if message.text:
await bot.send_message(
user.telegram_id, user.telegram_id,
message.text, message.text,
parse_mode="Markdown" parse_mode="Markdown"
) )
elif message.photo: elif message.photo:
await bot.send_photo( send_call = lambda: bot.send_photo(
user.telegram_id, user.telegram_id,
photo=message.photo[-1].file_id, photo=message.photo[-1].file_id,
caption=message.caption, caption=message.caption,
parse_mode="Markdown" parse_mode="Markdown"
) )
elif message.video: elif message.video:
await bot.send_video( send_call = lambda: bot.send_video(
user.telegram_id, user.telegram_id,
video=message.video.file_id, video=message.video.file_id,
caption=message.caption, caption=message.caption,
parse_mode="Markdown" parse_mode="Markdown"
) )
elif message.document: elif message.document:
await bot.send_document( send_call = lambda: bot.send_document(
user.telegram_id, user.telegram_id,
document=message.document.file_id, document=message.document.file_id,
caption=message.caption, caption=message.caption,
parse_mode="Markdown" parse_mode="Markdown"
) )
else: else:
# Копируем сообщение как есть send_call = lambda: message.copy_to(user.telegram_id)
await message.copy_to(user.telegram_id)
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее) result = await delivery_service.send_with_guard(
async with async_session_maker() as session: user.telegram_id,
await self.unblock_user(session, user.telegram_id) send_call,
retry=retry,
return True, None skip_block_check=skip_block_check,
)
except TelegramForbiddenError as e: return result.success, None if result.success else result.status
# Пользователь заблокировал бота
error_type = "blocked_bot"
async with async_session_maker() as session:
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
return False, error_type
except TelegramBadRequest as e:
# Пользователь удален или деактивирован
error_str = str(e).lower()
if "user is deactivated" in error_str:
error_type = "deactivated"
elif "user not found" in error_str:
error_type = "not_found"
elif "chat not found" in error_str:
error_type = "chat_not_found"
else:
error_type = "bad_request"
async with async_session_maker() as session:
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
return False, error_type
except TelegramRetryAfter as e:
# FloodWait - слишком много запросов
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
# Повторная попытка
return await self.send_message_to_user(bot, user, message)
except Exception as e:
# Другие ошибки
logger.error(f"Ошибка отправки пользователю {user.telegram_id}: {e}")
return False, "unknown_error"
async def broadcast_to_users( async def broadcast_to_users(
self, self,
@@ -307,6 +237,8 @@ class BroadcastService:
await session.refresh(broadcast_log) await session.refresh(broadcast_log)
log_id = broadcast_log.id log_id = broadcast_log.id
users_prefiltered = False
# Получаем список пользователей # Получаем список пользователей
if users is None: if users is None:
async with async_session_maker() as session: async with async_session_maker() as session:
@@ -324,6 +256,7 @@ class BroadcastService:
# Фильтруем пользователей, исключая заблокированных # Фильтруем пользователей, исключая заблокированных
users = [u for u in all_users if u.telegram_id not in blocked_ids] users = [u for u in all_users if u.telegram_id not in blocked_ids]
users_prefiltered = True
total_users = len(users) total_users = len(users)
success_count = 0 success_count = 0
@@ -337,7 +270,11 @@ class BroadcastService:
# Отправляем пакет # Отправляем пакет
tasks = [] tasks = []
for user in batch: for user in batch:
tasks.append(self.send_message_to_user(bot, user, message)) tasks.append(
self.send_message_to_user(
bot, user, message, skip_block_check=users_prefiltered
)
)
# Ждем завершения пакета # Ждем завершения пакета
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)

View File

@@ -11,9 +11,18 @@ if not BOT_TOKEN:
# База данных # База данных
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
DB_CONNECT_TIMEOUT = float(os.getenv("DB_CONNECT_TIMEOUT", "5"))
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
DB_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10"))
DB_POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "10"))
DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
# Redis # Redis
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
USE_REDIS_STORAGE = os.getenv("USE_REDIS_STORAGE", "true").lower() in {"1", "true", "yes", "on"}
# Активность пользователей
ACTIVITY_UPDATE_INTERVAL_SECONDS = int(os.getenv("ACTIVITY_UPDATE_INTERVAL_SECONDS", "300"))
# Администраторы # Администраторы
ADMIN_IDS = [] ADMIN_IDS = []

View File

@@ -8,12 +8,34 @@ load_dotenv()
# Конфигурация базы данных # Конфигурация базы данных
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
SQL_ECHO = os.getenv("SQL_ECHO", "false").lower() in {"1", "true", "yes", "on"}
DB_CONNECT_TIMEOUT = float(os.getenv("DB_CONNECT_TIMEOUT", "5"))
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
DB_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10"))
DB_POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "10"))
DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
# Создаем асинхронный движок # Создаем асинхронный движок
engine_kwargs = {
"echo": SQL_ECHO,
"future": True,
}
if DATABASE_URL.startswith("postgresql+asyncpg"):
engine_kwargs.update(
{
"pool_pre_ping": True,
"pool_size": DB_POOL_SIZE,
"max_overflow": DB_MAX_OVERFLOW,
"pool_timeout": DB_POOL_TIMEOUT,
"pool_recycle": DB_POOL_RECYCLE,
"connect_args": {"timeout": DB_CONNECT_TIMEOUT},
}
)
engine = create_async_engine( engine = create_async_engine(
DATABASE_URL, DATABASE_URL,
echo=True, # Логирование SQL запросов **engine_kwargs,
future=True,
) )
# Создаем фабрику сессий # Создаем фабрику сессий

185
src/core/delivery.py Normal file
View File

@@ -0,0 +1,185 @@
"""
Safe Telegram delivery helpers.
The bot sends messages from several places: broadcasts, winner notifications,
P2P chat, and chat forwarding. This module keeps the "user blocked the bot"
handling in one place so every outbound path behaves consistently.
"""
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Optional
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .database import async_session_maker
from .models import BlockedUser
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class DeliveryResult:
success: bool
status: Optional[str] = None
telegram_message: Any = None
skipped: bool = False
class DeliveryService:
"""Guarded Telegram delivery with persistent blocked-user tracking."""
RETRY_AFTER_DELAY = 5.0
MAX_RETRY_AFTER = 30
_BAD_REQUEST_ERROR_TYPES = {
"user is deactivated": "deactivated",
"user not found": "not_found",
"chat not found": "chat_not_found",
"bot was blocked by the user": "blocked_bot",
"forbidden": "blocked_bot",
}
async def check_user_blocked(
self,
session: AsyncSession,
telegram_id: int,
) -> Optional[BlockedUser]:
result = await session.execute(
select(BlockedUser).where(
BlockedUser.telegram_id == telegram_id,
BlockedUser.is_active == True,
)
)
return result.scalar_one_or_none()
async def mark_user_blocked(
self,
session: AsyncSession,
telegram_id: int,
error_type: str,
error_message: str,
) -> None:
now = datetime.now(timezone.utc)
result = await session.execute(
select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
)
blocked_user = result.scalar_one_or_none()
if blocked_user:
blocked_user.error_type = error_type
blocked_user.error_message = error_message
blocked_user.last_attempt_at = now
blocked_user.attempt_count = (blocked_user.attempt_count or 0) + 1
blocked_user.is_active = True
else:
session.add(
BlockedUser(
telegram_id=telegram_id,
error_type=error_type,
error_message=error_message,
first_blocked_at=now,
last_attempt_at=now,
attempt_count=1,
is_active=True,
)
)
await session.commit()
logger.info("User %s marked as unavailable: %s", telegram_id, error_type)
async def unblock_user(self, session: AsyncSession, telegram_id: int) -> bool:
blocked_user = await self.check_user_blocked(session, telegram_id)
if not blocked_user:
return False
blocked_user.is_active = False
blocked_user.last_attempt_at = datetime.now(timezone.utc)
await session.commit()
logger.info("User %s delivery reactivated", telegram_id)
return True
async def send_with_guard(
self,
telegram_id: int,
send_call: Callable[[], Awaitable[Any]],
*,
retry: bool = True,
skip_block_check: bool = False,
) -> DeliveryResult:
"""
Execute a Telegram send call unless the recipient is known unavailable.
Args:
telegram_id: recipient Telegram ID.
send_call: zero-argument async callable that performs the actual
aiogram send/copy operation and returns Telegram Message.
retry: retry once after TelegramRetryAfter when the delay is small.
skip_block_check: caller already filtered blocked users.
"""
if not skip_block_check:
async with async_session_maker() as session:
blocked_user = await self.check_user_blocked(session, telegram_id)
if blocked_user:
return DeliveryResult(
success=False,
status=blocked_user.error_type,
skipped=True,
)
try:
telegram_message = await send_call()
except TelegramForbiddenError as exc:
await self._mark_unavailable(telegram_id, "blocked_bot", str(exc))
return DeliveryResult(success=False, status="blocked_bot")
except TelegramBadRequest as exc:
error_type = self.classify_bad_request(exc)
if error_type:
await self._mark_unavailable(telegram_id, error_type, str(exc))
return DeliveryResult(success=False, status=error_type)
logger.warning("Telegram bad request for %s: %s", telegram_id, exc)
return DeliveryResult(success=False, status="bad_request")
except TelegramRetryAfter as exc:
if retry and exc.retry_after <= self.MAX_RETRY_AFTER:
logger.warning("FloodWait for %s: %s sec", telegram_id, exc.retry_after)
await asyncio.sleep(exc.retry_after + self.RETRY_AFTER_DELAY)
return await self.send_with_guard(
telegram_id,
send_call,
retry=False,
skip_block_check=True,
)
logger.warning("Skipping %s after FloodWait %s sec", telegram_id, exc.retry_after)
return DeliveryResult(success=False, status="retry_after")
except Exception as exc:
logger.error("Telegram delivery error for %s: %s", telegram_id, exc)
return DeliveryResult(success=False, status="unknown_error")
async with async_session_maker() as session:
await self.unblock_user(session, telegram_id)
return DeliveryResult(success=True, telegram_message=telegram_message)
def classify_bad_request(self, exc: TelegramBadRequest) -> Optional[str]:
error_text = str(exc).lower()
for marker, error_type in self._BAD_REQUEST_ERROR_TYPES.items():
if marker in error_text:
return error_type
return None
async def _mark_unavailable(
self,
telegram_id: int,
error_type: str,
error_message: str,
) -> None:
async with async_session_maker() as session:
await self.mark_user_blocked(session, telegram_id, error_type, error_message)
delivery_service = DeliveryService()

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint, BigInteger from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint, BigInteger, Index
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime, timezone from datetime import datetime, timezone
from .database import Base from .database import Base
@@ -8,6 +8,9 @@ import secrets
class User(Base): class User(Base):
"""Модель пользователя с регистрацией""" """Модель пользователя с регистрацией"""
__tablename__ = "users" __tablename__ = "users"
__table_args__ = (
Index("ix_users_registered_activity", "is_registered", "last_activity"),
)
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
telegram_id = Column(BigInteger, unique=True, nullable=False, index=True) telegram_id = Column(BigInteger, unique=True, nullable=False, index=True)
@@ -114,6 +117,10 @@ class Lottery(Base):
class Participation(Base): class Participation(Base):
"""Модель участия в розыгрыше""" """Модель участия в розыгрыше"""
__tablename__ = "participations" __tablename__ = "participations"
__table_args__ = (
Index("ix_participations_lottery_account", "lottery_id", "account_number"),
Index("ix_participations_user_created", "user_id", "created_at"),
)
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
@@ -136,6 +143,10 @@ class Participation(Base):
class Winner(Base): class Winner(Base):
"""Модель победителя розыгрыша""" """Модель победителя розыгрыша"""
__tablename__ = "winners" __tablename__ = "winners"
__table_args__ = (
Index("ix_winners_lottery_place", "lottery_id", "place"),
Index("ix_winners_user_id", "user_id"),
)
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False) lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
@@ -271,6 +282,9 @@ class BroadcastChannel(Base):
class BlockedUser(Base): class BlockedUser(Base):
"""Пользователи, которые заблокировали бота или недоступны""" """Пользователи, которые заблокировали бота или недоступны"""
__tablename__ = "blocked_users" __tablename__ = "blocked_users"
__table_args__ = (
Index("ix_blocked_users_lookup", "telegram_id", "error_type", "is_active"),
)
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
telegram_id = Column(BigInteger, nullable=False, unique=True, index=True) telegram_id = Column(BigInteger, nullable=False, unique=True, index=True)

View File

@@ -5,6 +5,7 @@ from .models import User, Account, Winner, WinnerVerification
from typing import Optional, List from typing import Optional, List
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import secrets import secrets
from ..utils.account_utils import format_account_number
class RegistrationService: class RegistrationService:
@@ -78,6 +79,10 @@ class AccountService:
account_number: str account_number: str
) -> Account: ) -> Account:
"""Создать новый счет для пользователя по номеру клубной карты""" """Создать новый счет для пользователя по номеру клубной карты"""
formatted_account = format_account_number(account_number)
if not formatted_account:
raise ValueError(f"Некорректный формат счета: {account_number}")
# Находим владельца по клубной карте # Находим владельца по клубной карте
user_result = await session.execute( user_result = await session.execute(
select(User).where(User.club_card_number == club_card_number) select(User).where(User.club_card_number == club_card_number)
@@ -89,14 +94,14 @@ class AccountService:
# Проверяем, не существует ли уже такой счет # Проверяем, не существует ли уже такой счет
existing = await session.execute( existing = await session.execute(
select(Account).where(Account.account_number == account_number) select(Account).where(Account.account_number == formatted_account)
) )
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise ValueError(f"Счет {account_number} уже существует") raise ValueError(f"Счет {formatted_account} уже существует")
# Создаем счет # Создаем счет
account = Account( account = Account(
account_number=account_number, account_number=formatted_account,
owner_id=user.id, owner_id=user.id,
is_active=True is_active=True
) )
@@ -112,10 +117,14 @@ class AccountService:
account_number: str account_number: str
) -> Optional[User]: ) -> Optional[User]:
"""Найти владельца счета""" """Найти владельца счета"""
formatted_account = format_account_number(account_number)
if not formatted_account:
return None
result = await session.execute( result = await session.execute(
select(Account).where( select(Account).where(
and_( and_(
Account.account_number == account_number, Account.account_number == formatted_account,
Account.is_active == True Account.is_active == True
) )
) )
@@ -150,8 +159,12 @@ class AccountService:
account_number: str account_number: str
) -> bool: ) -> bool:
"""Деактивировать счет""" """Деактивировать счет"""
formatted_account = format_account_number(account_number)
if not formatted_account:
return False
result = await session.execute( result = await session.execute(
select(Account).where(Account.account_number == account_number) select(Account).where(Account.account_number == formatted_account)
) )
account = result.scalar_one_or_none() account = result.scalar_one_or_none()

View File

@@ -1,10 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete from sqlalchemy import select, update, delete, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from .models import User, Lottery, Participation, Winner, Account from .models import User, Lottery, Participation, Winner, Account
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from ..utils.account_utils import validate_account_number, format_account_number from ..utils.account_utils import validate_account_number, format_account_number
import random import random
from types import SimpleNamespace
class UserService: class UserService:
@@ -129,27 +130,39 @@ class UserService:
@staticmethod @staticmethod
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool: async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool:
"""Установить номер клиентского счета пользователю""" """Привязать номер клиентского счета к пользователю.
Исторически номер счета хранился в User.account_number. В актуальной
схеме счета живут в отдельной таблице Account, поэтому метод оставлен
как совместимый фасад для старых обработчиков и тестов.
"""
# Валидируем и форматируем номер # Валидируем и форматируем номер
formatted_number = format_account_number(account_number) formatted_number = format_account_number(account_number)
if not formatted_number: if not formatted_number:
return False return False
# Проверяем уникальность номера user = await UserService.get_user_by_telegram_id(session, telegram_id)
existing = await session.execute( if not user:
select(User).where(User.account_number == formatted_number) return False
)
if existing.scalar_one_or_none():
return False # Номер уже занят
# Обновляем пользователя existing = await session.execute(
result = await session.execute( select(Account).where(Account.account_number == formatted_number)
update(User)
.where(User.telegram_id == telegram_id)
.values(account_number=formatted_number)
) )
account = existing.scalar_one_or_none()
if account:
if account.owner_id != user.id:
return False
account.is_active = True
else:
session.add(Account(
account_number=formatted_number,
owner_id=user.id,
is_active=True
))
user.account_number = formatted_number
await session.commit() await session.commit()
return result.rowcount > 0 return True
@staticmethod @staticmethod
async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]: async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]:
@@ -158,6 +171,22 @@ class UserService:
if not formatted_number: if not formatted_number:
return None return None
result = await session.execute(
select(User, Account.account_number)
.join(Account, Account.owner_id == User.id)
.where(
Account.account_number == formatted_number,
Account.is_active == True
)
)
row = result.one_or_none()
if not row:
return None
user, account = row
user.account_number = account
return user
@staticmethod @staticmethod
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]: async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
""" """
@@ -174,12 +203,6 @@ class UserService:
select(User).where(User.club_card_number == club_card_number) select(User).where(User.club_card_number == club_card_number)
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
result = await session.execute(
select(User).where(User.account_number == formatted_number)
)
return result.scalar_one_or_none()
@staticmethod @staticmethod
async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]: async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]:
"""Поиск пользователей по части номера счета""" """Поиск пользователей по части номера счета"""
@@ -189,11 +212,17 @@ class UserService:
return [] return []
result = await session.execute( result = await session.execute(
select(User).where( select(User, Account.account_number)
User.account_number.like(f'%{clean_pattern}%') .join(Account, Account.owner_id == User.id)
).limit(20) .where(Account.account_number.like(f'%{clean_pattern}%'))
.limit(20)
) )
return result.scalars().all()
users = []
for user, account_number in result.all():
user.account_number = account_number
users.append(user)
return users
class LotteryService: class LotteryService:
@@ -307,15 +336,25 @@ class LotteryService:
participants = [] participants = []
for p in lottery.participations: for p in lottery.participations:
if p.user: if p.user:
participants.append(p.user) participants.append(SimpleNamespace(
id=p.user.id,
telegram_id=p.user.telegram_id,
username=p.user.username,
first_name=p.user.first_name,
last_name=p.user.last_name,
account_number=p.account_number
))
else: else:
# Создаем временный объект для участников без пользователя # Создаем временный объект для участников без пользователя
# Храним только номер счета # Храним только номер счета
participants.append(type('obj', (object,), { participants.append(SimpleNamespace(
'id': None, id=None,
'telegram_id': None, telegram_id=None,
'account_number': p.account_number username=None,
})()) first_name=None,
last_name=None,
account_number=p.account_number
))
logger.info(f"conduct_draw: участников {len(participants)}") logger.info(f"conduct_draw: участников {len(participants)}")
if not participants: if not participants:
@@ -503,13 +542,22 @@ class ParticipationService:
@staticmethod @staticmethod
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]: async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
"""Получить участников розыгрыша""" """Получить участников розыгрыша"""
query = select(User).join(Participation).where(Participation.lottery_id == lottery_id) query = (
select(Participation)
.options(selectinload(Participation.user))
.where(Participation.lottery_id == lottery_id)
)
if limit: if limit:
query = query.offset(offset).limit(limit) query = query.offset(offset).limit(limit)
result = await session.execute(query) result = await session.execute(query)
return list(result.scalars().all()) participants = []
for participation in result.scalars().all():
if participation.user:
participation.user.account_number = participation.account_number
participants.append(participation.user)
return participants
@staticmethod @staticmethod
async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]: async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]:
@@ -525,11 +573,9 @@ class ParticipationService:
@staticmethod @staticmethod
async def get_participants_count(session: AsyncSession, lottery_id: int) -> int: async def get_participants_count(session: AsyncSession, lottery_id: int) -> int:
"""Получить количество участников в розыгрыше""" """Получить количество участников в розыгрыше"""
result = await session.execute( return await session.scalar(
select(Participation) select(func.count(Participation.id)).where(Participation.lottery_id == lottery_id)
.where(Participation.lottery_id == lottery_id)
) )
return len(result.scalars().all())
@staticmethod @staticmethod
async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]: async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
@@ -595,9 +641,6 @@ class ParticipationService:
@staticmethod @staticmethod
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]: async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
"""Массовое добавление участников по номерам счетов""" """Массовое добавление участников по номерам счетов"""
import logging
logger = logging.getLogger(__name__)
results = { results = {
"added": 0, "added": 0,
"skipped": 0, "skipped": 0,
@@ -611,38 +654,29 @@ class ParticipationService:
if not account_input: if not account_input:
continue continue
logger.info(f"DEBUG: Processing account_input={account_input!r}")
try: try:
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета # Разделяем по пробелу: левая часть - номер карты, правая - номер счета
parts = account_input.split() parts = account_input.split()
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
if len(parts) == 2: if len(parts) == 2:
card_number = parts[0] # Номер клубной карты card_number = parts[0] # Номер клубной карты
account_number = parts[1] # Номер счета account_number = parts[1] # Номер счета
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
elif len(parts) == 1: elif len(parts) == 1:
# Если нет пробела, считаем что это просто номер счета # Если нет пробела, считаем что это просто номер счета
card_number = None card_number = None
account_number = parts[0] account_number = parts[0]
logger.info(f"DEBUG: 1 part - account={account_number!r}")
else: else:
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
results["invalid_accounts"].append(account_input) results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат: {account_input}") results["errors"].append(f"Неверный формат: {account_input}")
continue continue
# Валидируем и форматируем номер счета # Валидируем и форматируем номер счета
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
formatted_account = format_account_number(account_number) formatted_account = format_account_number(account_number)
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
if not formatted_account: if not formatted_account:
card_info = f" (карта: {card_number})" if card_number else "" card_info = f" (карта: {card_number})" if card_number else ""
results["invalid_accounts"].append(account_input) results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}") results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
logger.error(f"DEBUG: Format failed for {account_number!r}")
continue continue
# Ищем владельца счёта через таблицу Account # Ищем владельца счёта через таблицу Account
@@ -687,7 +721,6 @@ class ParticipationService:
account_number=formatted_account account_number=formatted_account
) )
session.add(participation) session.add(participation)
await session.commit()
results["added"] += 1 results["added"] += 1
detail = f"{user.first_name} ({formatted_account})" detail = f"{user.first_name} ({formatted_account})"
@@ -698,6 +731,9 @@ class ParticipationService:
except Exception as e: except Exception as e:
results["errors"].append(f"Ошибка с {account_input}: {str(e)}") results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
if results["added"]:
await session.commit()
return results return results
@staticmethod @staticmethod
@@ -759,7 +795,6 @@ class ParticipationService:
if participation: if participation:
await session.delete(participation) await session.delete(participation)
await session.commit()
results["removed"] += 1 results["removed"] += 1
detail = f"{user.first_name} ({formatted_account})" detail = f"{user.first_name} ({formatted_account})"
@@ -776,13 +811,14 @@ class ParticipationService:
except Exception as e: except Exception as e:
results["errors"].append(f"Ошибка с {account_input}: {str(e)}") results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
if results["removed"]:
await session.commit()
return results return results
@staticmethod @staticmethod
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]: async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
"""Статистика участника""" """Статистика участника"""
from sqlalchemy import func
# Количество участий # Количество участий
participations_count = await session.scalar( participations_count = await session.scalar(
select(func.count(Participation.id)).where(Participation.user_id == user_id) select(func.count(Participation.id)).where(Participation.user_id == user_id)
@@ -805,3 +841,41 @@ class ParticipationService:
"wins_count": wins_count, "wins_count": wins_count,
"last_participation": last_participation.created_at if last_participation else None "last_participation": last_participation.created_at if last_participation else None
} }
@staticmethod
async def get_participant_stats_bulk(session: AsyncSession, user_ids: List[int]) -> Dict[int, Dict[str, Any]]:
"""Статистика участников одним набором агрегирующих запросов."""
if not user_ids:
return {}
stats = {
user_id: {
"participations_count": 0,
"wins_count": 0,
"last_participation": None,
}
for user_id in user_ids
}
participations = await session.execute(
select(
Participation.user_id,
func.count(Participation.id),
func.max(Participation.created_at),
)
.where(Participation.user_id.in_(user_ids))
.group_by(Participation.user_id)
)
for user_id, count, last_participation in participations.all():
stats[user_id]["participations_count"] = count
stats[user_id]["last_participation"] = last_participation
wins = await session.execute(
select(Winner.user_id, func.count(Winner.id))
.where(Winner.user_id.in_(user_ids))
.group_by(Winner.user_id)
)
for user_id, count in wins.all():
stats[user_id]["wins_count"] = count
return stats

View File

@@ -6,7 +6,6 @@ import asyncio
import json import json
from ..core.database import async_session_maker from ..core.database import async_session_maker
from ..core.services import LotteryService, ParticipationService from ..core.services import LotteryService, ParticipationService
from ..core.models import Lottery, User
async def conduct_lottery_draw(lottery_id: int): async def conduct_lottery_draw(lottery_id: int):
"""Проводим розыгрыш для указанного ID""" """Проводим розыгрыш для указанного ID"""
@@ -36,8 +35,8 @@ async def conduct_lottery_draw(lottery_id: int):
# Выводим список участников # Выводим список участников
print("\n👥 Список участников:") print("\n👥 Список участников:")
for i, user in enumerate(participants, 1): for i, user in enumerate(participants, 1):
accounts = user.account_number.split(',') if user.account_number else ['Нет счетов'] username = f"@{user.username}" if user.username else "без username"
print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)") print(f" {i}. {user.first_name or 'Без имени'} ({username})")
# Проводим розыгрыш # Проводим розыгрыш
print(f"\n🎲 Проводим розыгрыш...") print(f"\n🎲 Проводим розыгрыш...")
@@ -48,14 +47,15 @@ async def conduct_lottery_draw(lottery_id: int):
if winners: if winners:
print(f"\n🎉 Победители определены:") print(f"\n🎉 Победители определены:")
for i, winner_data in enumerate(winners, 1): for place, winner_data in winners.items():
user = winner_data['user'] user = winner_data['user']
prize = winner_data['prize'] prize = winner_data['prize']
print(f" 🏆 {i} место: {user.first_name} (@{user.username})") name = user.first_name or getattr(user, "account_number", None) or "Неизвестный участник"
username = f" (@{user.username})" if getattr(user, 'username', None) else ""
print(f" 🏆 {place} место: {name}{username}")
print(f" 💎 Приз: {prize}") print(f" 💎 Приз: {prize}")
# Обновляем статус розыгрыша await session.commit()
await LotteryService.set_lottery_completed(session, lottery_id, True)
print(f"\n✅ Розыгрыш завершен и помечен как завершенный") print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
else: else:

View File

@@ -3,7 +3,7 @@
""" """
import asyncio import asyncio
from ..core.database import async_session_maker, init_db from ..core.database import async_session_maker, init_db
from ..core.services import UserService, LotteryService from ..core.services import UserService, LotteryService, ParticipationService
from ..utils.admin_utils import AdminUtils, ReportGenerator from ..utils.admin_utils import AdminUtils, ReportGenerator
@@ -87,17 +87,17 @@ async def demo_admin_features():
participants_added = 0 participants_added = 0
for user in users: for user in users:
# В первый розыгрыш добавляем всех # В первый розыгрыш добавляем всех
if await LotteryService.add_participant(session, lottery1.id, user.id): if await ParticipationService.add_participant(session, lottery1.id, user.id):
participants_added += 1 participants_added += 1
# Во второй - половину # Во второй - половину
if user.id % 2 == 0: if user.id % 2 == 0:
if await LotteryService.add_participant(session, lottery2.id, user.id): if await ParticipationService.add_participant(session, lottery2.id, user.id):
participants_added += 1 participants_added += 1
# В третий - треть # В третий - треть
if user.id % 3 == 0: if user.id % 3 == 0:
if await LotteryService.add_participant(session, lottery3.id, user.id): if await ParticipationService.add_participant(session, lottery3.id, user.id):
participants_added += 1 participants_added += 1
print(f"✅ Добавлено {participants_added} участий") print(f"✅ Добавлено {participants_added} участий")
@@ -127,6 +127,7 @@ async def demo_admin_features():
# Первый розыгрыш # Первый розыгрыш
results1 = await LotteryService.conduct_draw(session, lottery1.id) results1 = await LotteryService.conduct_draw(session, lottery1.id)
await session.commit()
print(f" 🎉 {lottery1.title} - результаты:") print(f" 🎉 {lottery1.title} - результаты:")
for place, winner_info in results1.items(): for place, winner_info in results1.items():
user = winner_info['user'] user = winner_info['user']
@@ -135,6 +136,7 @@ async def demo_admin_features():
# Второй розыгрыш # Второй розыгрыш
results2 = await LotteryService.conduct_draw(session, lottery2.id) results2 = await LotteryService.conduct_draw(session, lottery2.id)
await session.commit()
print(f" 🚗 {lottery2.title} - результаты:") print(f" 🚗 {lottery2.title} - результаты:")
for place, winner_info in results2.items(): for place, winner_info in results2.items():
user = winner_info['user'] user = winner_info['user']

View File

@@ -19,6 +19,7 @@ async def conduct_simple_draw():
try: try:
# Проводим розыгрыш # Проводим розыгрыш
winners = await LotteryService.conduct_draw(session, lottery_id) winners = await LotteryService.conduct_draw(session, lottery_id)
await session.commit()
print(f"🎉 Розыгрыш проведен!") print(f"🎉 Розыгрыш проведен!")
print(f"📊 Результат: {winners}") print(f"📊 Результат: {winners}")

View File

@@ -6,6 +6,21 @@ from ..core.models import User, Lottery
from ..utils.account_utils import mask_account_number from ..utils.account_utils import mask_account_number
def _get_user_account_number(user: User) -> Optional[str]:
"""Вернуть номер счета из совместимого поля или уже загруженной связи."""
account_number = getattr(user, 'account_number', None)
if account_number:
return account_number
# Не инициируем lazy-load в async SQLAlchemy: берем только уже загруженную связь.
accounts = getattr(user, '__dict__', {}).get('accounts') or []
for account in accounts:
if getattr(account, 'is_active', True) and getattr(account, 'account_number', None):
return account.account_number
return None
def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: bool = False) -> str: def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: bool = False) -> str:
""" """
Форматирует отображение победителя в зависимости от настроек розыгрыша Форматирует отображение победителя в зависимости от настроек розыгрыша
@@ -33,15 +48,16 @@ def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: boo
elif display_type == 'account_number': elif display_type == 'account_number':
# Отображаем номер клиентского счета # Отображаем номер клиентского счета
if not user.account_number: account_number = _get_user_account_number(user)
if not account_number:
return "Счёт не указан" return "Счёт не указан"
if show_sensitive_data: if show_sensitive_data:
# Для админов показываем полный номер # Для админов показываем полный номер
return f"Счёт: {user.account_number}" return f"Счёт: {account_number}"
else: else:
# Для публичного показа маскируем номер # Для публичного показа маскируем номер
masked = mask_account_number(user.account_number, show_last_digits=4) masked = mask_account_number(account_number, show_last_digits=4)
return f"Счёт: {masked}" return f"Счёт: {masked}"
else: else:

View File

@@ -15,7 +15,8 @@ class AccountParticipationService:
async def add_account_to_lottery( async def add_account_to_lottery(
session: AsyncSession, session: AsyncSession,
lottery_id: int, lottery_id: int,
account_number: str account_number: str,
commit: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Добавить счет в розыгрыш. Добавить счет в розыгрыш.
@@ -91,7 +92,10 @@ class AccountParticipationService:
account_id=account_record.id if account_record else None account_id=account_record.id if account_record else None
) )
session.add(participation) session.add(participation)
await session.commit() if commit:
await session.commit()
else:
await session.flush()
card_info = f" (карта: {card_number})" if card_number else "" card_info = f" (карта: {card_number})" if card_number else ""
return { return {
@@ -120,7 +124,7 @@ class AccountParticipationService:
for account in account_numbers: for account in account_numbers:
result = await AccountParticipationService.add_account_to_lottery( result = await AccountParticipationService.add_account_to_lottery(
session, lottery_id, account session, lottery_id, account, commit=False
) )
if result["success"]: if result["success"]:
@@ -133,13 +137,16 @@ class AccountParticipationService:
results["errors"].append(result["message"]) results["errors"].append(result["message"])
results["details"].append(f"{result['message']}") results["details"].append(f"{result['message']}")
await session.commit()
return results return results
@staticmethod @staticmethod
async def remove_account_from_lottery( async def remove_account_from_lottery(
session: AsyncSession, session: AsyncSession,
lottery_id: int, lottery_id: int,
account_number: str account_number: str,
commit: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Удалить счет из розыгрыша""" """Удалить счет из розыгрыша"""
formatted_account = format_account_number(account_number) formatted_account = format_account_number(account_number)
@@ -164,7 +171,10 @@ class AccountParticipationService:
} }
await session.delete(participation) await session.delete(participation)
await session.commit() if commit:
await session.commit()
else:
await session.flush()
return { return {
"success": True, "success": True,
@@ -216,13 +226,21 @@ class AccountParticipationService:
pattern: Паттерн поиска (например "11-22" или "33") pattern: Паттерн поиска (например "11-22" или "33")
limit: Максимальное количество результатов limit: Максимальное количество результатов
""" """
# Получаем все счета розыгрыша digits_pattern = ''.join(c for c in pattern if c.isdigit())
all_accounts = await AccountParticipationService.get_lottery_accounts( if not digits_pattern:
session, lottery_id return []
)
# Ищем совпадения result = await session.execute(
return search_accounts_by_pattern(pattern, all_accounts)[:limit] select(Participation.account_number)
.where(
Participation.lottery_id == lottery_id,
Participation.account_number.isnot(None),
func.replace(Participation.account_number, '-', '').contains(digits_pattern),
)
.order_by(Participation.created_at.desc())
.limit(limit)
)
return [account for account in result.scalars().all() if account]
@staticmethod @staticmethod
async def set_account_as_winner( async def set_account_as_winner(

View File

@@ -1,6 +1,7 @@
""" """
Расширенная админ-панель для управления розыгрышами Расширенная админ-панель для управления розыгрышами
""" """
import asyncio
import logging import logging
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import ( from aiogram.types import (
@@ -24,6 +25,107 @@ from ..core.models import User, Lottery, Participation, Account, ChatMessage, Wi
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _build_users_export_file(users_data: list[dict]) -> bytes:
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = Workbook()
ws = wb.active
ws.title = "Пользователи"
headers = [
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
]
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
for row_num, user in enumerate(users_data, 2):
ws.cell(row=row_num, column=1, value=user["telegram_id"])
ws.cell(row=row_num, column=2, value=user["username"] or '')
ws.cell(row=row_num, column=3, value=user["first_name"] or '')
ws.cell(row=row_num, column=4, value=user["last_name"] or '')
ws.cell(row=row_num, column=5, value=user["nickname"] or '')
ws.cell(row=row_num, column=6, value=user["phone"] or '')
ws.cell(row=row_num, column=7, value=user["club_card_number"] or '')
ws.cell(row=row_num, column=8, value='Да' if user["is_registered"] else 'Нет')
ws.cell(row=row_num, column=9, value='Да' if user["is_admin"] else 'Нет')
ws.cell(row=row_num, column=10, value=user["verification_code"] or '')
ws.cell(row=row_num, column=11, value=user["created_at"] or '')
ws.cell(row=row_num, column=12, value=user["last_activity"] or '')
ws.cell(row=row_num, column=13, value='Да' if user["is_chat_banned"] else 'Нет')
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
max_length = max(max_length, len(str(cell.value)))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
excel_file = BytesIO()
wb.save(excel_file)
return excel_file.getvalue()
def _parse_users_xlsx(file_bytes: bytes) -> list[dict]:
from io import BytesIO
from openpyxl import load_workbook
wb = load_workbook(BytesIO(file_bytes), read_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if len(rows) < 2:
return []
headers = [h if h else '' for h in rows[0]]
telegram_id_idx = headers.index('Telegram ID')
field_mapping = {
'Username': 'username',
'Имя': 'first_name',
'Фамилия': 'last_name',
'Никнейм': 'nickname',
'Телефон': 'phone',
'Клубная карта': 'club_card_number',
'Зарегистрирован': 'is_registered',
'Код верификации': 'verification_code'
}
users_data = []
for row in rows[1:]:
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
continue
user_dict = {'telegram_id': row[telegram_id_idx]}
for header_name, field_name in field_mapping.items():
try:
idx = headers.index(header_name)
if idx < len(row):
value = row[idx]
user_dict[field_name] = (
value in ['Да', 'Yes', 'True', True, 1]
if field_name == 'is_registered'
else value if value else None
)
except (ValueError, IndexError):
user_dict[field_name] = None
users_data.append(user_dict)
return users_data
async def safe_edit_message( async def safe_edit_message(
callback: CallbackQuery, callback: CallbackQuery,
text: str, text: str,
@@ -179,7 +281,8 @@ def get_participant_management_keyboard() -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="📋 Список всех", callback_data="admin_list_all_participants"), [InlineKeyboardButton(text="📋 Список всех", callback_data="admin_list_all_participants"),
InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_search_participants")], InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_search_participants")],
[InlineKeyboardButton(text="📊 По розыгрышам", callback_data="admin_participants_by_lottery")], [InlineKeyboardButton(text="📊 По розыгрышам", callback_data="admin_participants_by_lottery")],
[InlineKeyboardButton(text="📄 Отчет", callback_data="admin_participants_report")], [InlineKeyboardButton(text="📄 Отчет", callback_data="admin_participants_report"),
InlineKeyboardButton(text="🏦 Счета", callback_data="admin_participants_accounts_report")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")] [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
] ]
return InlineKeyboardMarkup(inline_keyboard=buttons) return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -979,12 +1082,10 @@ async def list_all_participants(callback: CallbackQuery):
async with async_session_maker() as session: async with async_session_maker() as session:
users = await UserService.get_all_users(session, limit=50) users = await UserService.get_all_users(session, limit=50)
stats_by_user = await ParticipationService.get_participant_stats_bulk(
# Получаем статистику для каждого пользователя session, [user.id for user in users]
user_stats = [] )
for user in users: user_stats = [(user, stats_by_user[user.id]) for user in users]
stats = await ParticipationService.get_participant_stats(session, user.id)
user_stats.append((user, stats))
if not user_stats: if not user_stats:
await callback.message.edit_text( await callback.message.edit_text(
@@ -1115,6 +1216,9 @@ async def export_participants_data(callback: CallbackQuery):
async with async_session_maker() as session: async with async_session_maker() as session:
users = await UserService.get_all_users(session) users = await UserService.get_all_users(session)
stats_by_user = await ParticipationService.get_participant_stats_bulk(
session, [user.id for user in users]
)
export_data = { export_data = {
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
@@ -1123,7 +1227,7 @@ async def export_participants_data(callback: CallbackQuery):
} }
for user in users: for user in users:
stats = await ParticipationService.get_participant_stats(session, user.id) stats = stats_by_user[user.id]
user_data = { user_data = {
"id": user.id, "id": user.id,
"telegram_id": user.telegram_id, "telegram_id": user.telegram_id,
@@ -1191,12 +1295,10 @@ async def process_search_participants(message: Message, state: FSMContext):
async with async_session_maker() as session: async with async_session_maker() as session:
users = await UserService.search_users(session, search_term) users = await UserService.search_users(session, search_term)
stats_by_user = await ParticipationService.get_participant_stats_bulk(
# Получаем статистику для найденных пользователей session, [user.id for user in users]
user_stats = [] )
for user in users: user_stats = [(user, stats_by_user[user.id]) for user in users]
stats = await ParticipationService.get_participant_stats(session, user.id)
user_stats.append((user, stats))
await state.clear() await state.clear()
@@ -2359,16 +2461,16 @@ async def show_participants_by_lottery(callback: CallbackQuery):
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data == "admin_participants_report") @admin_router.callback_query(F.data == "admin_participants_accounts_report")
async def show_participants_report(callback: CallbackQuery): async def show_participants_accounts_report(callback: CallbackQuery):
"""Отчет по участникам""" """Отчет по участникам и привязанным счетам"""
if not await check_admin_access(callback.from_user.id): if not await check_admin_access(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True) await callback.answer("❌ Недостаточно прав", show_alert=True)
return return
async with async_session_maker() as session: async with async_session_maker() as session:
from sqlalchemy import func, select from sqlalchemy import func, select
from ..core.models import User, Participation, Lottery from ..core.models import User, Participation, Account
# Общая статистика по участникам # Общая статистика по участникам
total_participants = await session.scalar( total_participants = await session.scalar(
@@ -2378,32 +2480,33 @@ async def show_participants_report(callback: CallbackQuery):
) )
total_participations = await session.scalar(select(func.count(Participation.id))) total_participations = await session.scalar(select(func.count(Participation.id)))
total_users = await session.scalar(select(func.count(User.id)))
# Топ активных участников # Топ активных участников
top_participants = await session.execute( top_participants = await session.execute(
select( select(
User.first_name, User.first_name,
User.username, User.username,
User.account_number, func.min(Account.account_number).label('account_sample'),
func.count(Participation.id).label('participations') func.count(func.distinct(Participation.id)).label('participations')
) )
.select_from(User)
.join(Participation) .join(Participation)
.outerjoin(Account, Account.owner_id == User.id)
.group_by(User.id) .group_by(User.id)
.order_by(func.count(Participation.id).desc()) .order_by(func.count(func.distinct(Participation.id)).desc())
.limit(10) .limit(10)
) )
top_participants = top_participants.fetchall() top_participants = top_participants.fetchall()
# Участники с аккаунтами vs без # Участники с аккаунтами vs без
users_with_accounts = await session.scalar( users_with_accounts = await session.scalar(
select(func.count(User.id)).where(User.account_number.isnot(None)) select(func.count(func.distinct(Account.owner_id)))
) )
users_without_accounts = await session.scalar( users_without_accounts = max((total_users or 0) - (users_with_accounts or 0), 0)
select(func.count(User.id)).where(User.account_number.is_(None))
)
text = "📈 Отчет по участникам\n\n" text = "📈 Отчет по участникам и счетам\n\n"
text += f"👥 Всего уникальных участников: {total_participants}\n" text += f"👥 Всего уникальных участников: {total_participants}\n"
text += f"📊 Всего участий: {total_participations}\n" text += f"📊 Всего участий: {total_participations}\n"
text += f"🏦 С номерами счетов: {users_with_accounts}\n" text += f"🏦 С номерами счетов: {users_with_accounts}\n"
@@ -2420,7 +2523,7 @@ async def show_participants_report(callback: CallbackQuery):
await callback.message.edit_text( await callback.message.edit_text(
text, text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔃 Обновить", callback_data="admin_participants_report")], [InlineKeyboardButton(text="🔃 Обновить", callback_data="admin_participants_accounts_report")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_participants")] [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_participants")]
]) ])
) )
@@ -3554,20 +3657,10 @@ async def conduct_lottery_draw(callback: CallbackQuery):
await session.commit() await session.commit()
logger.info(f"Изменения закоммичены для розыгрыша {lottery_id}") logger.info(f"Изменения закоммичены для розыгрыша {lottery_id}")
# Отправляем уведомления победителям asyncio.create_task(
from ..utils.notifications import notify_winners_async _send_draw_notifications_background(callback.bot, lottery_id, winners_dict)
try: )
await notify_winners_async(callback.bot, session, lottery_id) logger.info(f"Фоновая рассылка итогов запущена для розыгрыша {lottery_id}")
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}")
# Отправляем результаты розыгрыша всем участникам (кроме победителей)
try:
await _notify_all_participants_about_results(callback.bot, session, lottery_id, winners_dict)
logger.info(f"Результаты розыгрыша разосланы всем участникам {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при рассылке результатов: {e}")
# Получаем победителей из базы # Получаем победителей из базы
winners = await LotteryService.get_winners(session, lottery_id) winners = await LotteryService.get_winners(session, lottery_id)
@@ -4482,6 +4575,24 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
logger.info(f"Результаты розыгрыша разосланы: {success_count} успешно, {fail_count} ошибок") logger.info(f"Результаты розыгрыша разосланы: {success_count} успешно, {fail_count} ошибок")
async def _send_draw_notifications_background(bot, lottery_id: int, winners_dict: dict):
"""Отправить уведомления по итогам розыгрыша вне callback handler."""
async with async_session_maker() as session:
from ..utils.notifications import notify_winners_async
try:
await notify_winners_async(bot, session, lottery_id)
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}", exc_info=True)
try:
await _notify_all_participants_about_results(bot, session, lottery_id, winners_dict)
logger.info(f"Результаты розыгрыша разосланы всем участникам {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при рассылке результатов: {e}", exc_info=True)
# ============================================================================ # ============================================================================
# ЭКСПОРТ И ИМПОРТ ПОЛЬЗОВАТЕЛЕЙ # ЭКСПОРТ И ИМПОРТ ПОЛЬЗОВАТЕЛЕЙ
# ============================================================================ # ============================================================================
@@ -4496,87 +4607,45 @@ async def admin_export_users(callback: CallbackQuery):
await callback.answer("⏳ Формирую файл...", show_alert=False) await callback.answer("⏳ Формирую файл...", show_alert=False)
try: try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from io import BytesIO
from aiogram.types import BufferedInputFile from aiogram.types import BufferedInputFile
async with async_session_maker() as session: async with async_session_maker() as session:
# Получаем всех пользователей
all_users = await UserService.get_all_users(session) all_users = await UserService.get_all_users(session)
# Создаем Excel файл users_data = [
wb = Workbook() {
ws = wb.active "telegram_id": user.telegram_id,
ws.title = "Пользователи" "username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"nickname": user.nickname,
"phone": user.phone,
"club_card_number": user.club_card_number,
"is_registered": user.is_registered,
"is_admin": user.is_admin,
"verification_code": user.verification_code,
"created_at": user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '',
"last_activity": user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '',
"is_chat_banned": user.is_chat_banned,
}
for user in all_users
]
# Заголовки excel_bytes = await asyncio.to_thread(_build_users_export_file, users_data)
headers = [ filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм', file = BufferedInputFile(excel_bytes, filename=filename)
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ', registered_count = sum(1 for user in all_users if user.is_registered)
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
]
# Стиль для заголовков await callback.message.answer_document(
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") document=file,
header_font = Font(bold=True, color="FFFFFF") caption=(
f"📥 <b>Экспорт пользователей</b>\n\n"
for col_num, header in enumerate(headers, 1): f"📊 Всего пользователей: {len(all_users)}\n"
cell = ws.cell(row=1, column=col_num, value=header) f"✅ Зарегистрировано: {registered_count}\n"
cell.fill = header_fill f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
cell.font = header_font ),
cell.alignment = Alignment(horizontal="center", vertical="center") parse_mode="HTML"
)
# Данные пользователей
for row_num, user in enumerate(all_users, 2):
ws.cell(row=row_num, column=1, value=user.telegram_id)
ws.cell(row=row_num, column=2, value=user.username or '')
ws.cell(row=row_num, column=3, value=user.first_name or '')
ws.cell(row=row_num, column=4, value=user.last_name or '')
ws.cell(row=row_num, column=5, value=user.nickname or '')
ws.cell(row=row_num, column=6, value=user.phone or '')
ws.cell(row=row_num, column=7, value=user.club_card_number or '')
ws.cell(row=row_num, column=8, value='Да' if user.is_registered else 'Нет')
ws.cell(row=row_num, column=9, value='Да' if user.is_admin else 'Нет')
ws.cell(row=row_num, column=10, value=user.verification_code or '')
ws.cell(row=row_num, column=11, value=user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '')
ws.cell(row=row_num, column=12, value=user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '')
ws.cell(row=row_num, column=13, value='Да' if user.is_chat_banned else 'Нет')
# Автоподбор ширины колонок
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# Сохраняем в BytesIO
excel_file = BytesIO()
wb.save(excel_file)
excel_file.seek(0)
# Отправляем файл
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
file = BufferedInputFile(excel_file.read(), filename=filename)
registered_count = len([u for u in all_users if u.is_registered])
await callback.message.answer_document(
document=file,
caption=(
f"📥 <b>Экспорт пользователей</b>\n\n"
f"📊 Всего пользователей: {len(all_users)}\n"
f"✅ Зарегистрировано: {registered_count}\n"
f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
),
parse_mode="HTML"
)
await callback.answer("✅ Файл отправлен", show_alert=False) await callback.answer("✅ Файл отправлен", show_alert=False)
except Exception as e: except Exception as e:
@@ -4631,69 +4700,21 @@ async def admin_import_users_process(message: Message, state: FSMContext):
status_msg = await message.answer("⏳ Загружаю файл...") status_msg = await message.answer("⏳ Загружаю файл...")
try: try:
from openpyxl import load_workbook
from io import BytesIO
# Скачиваем файл # Скачиваем файл
file = await message.bot.get_file(message.document.file_id) file = await message.bot.get_file(message.document.file_id)
file_content = await message.bot.download_file(file.file_path) file_content = await message.bot.download_file(file.file_path)
# Читаем Excel файл
excel_file = BytesIO(file_content.read())
wb = load_workbook(excel_file, read_only=True)
ws = wb.active
# Читаем данные
rows = list(ws.iter_rows(values_only=True))
if len(rows) < 2:
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
await state.clear()
return
# Первая строка - заголовки
headers = [h if h else '' for h in rows[0]]
# Находим индекс колонки Telegram ID
try: try:
telegram_id_idx = headers.index('Telegram ID') users_data = await asyncio.to_thread(_parse_users_xlsx, file_content.read())
except ValueError: except ValueError:
await status_msg.edit_text("Не найдена обязательная колонка 'Telegram ID'.") await status_msg.edit_text("Не найдена обязательная колонка 'Telegram ID'.")
await state.clear() await state.clear()
return return
# Создаем маппинг индексов для других полей if not users_data:
field_mapping = { await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
'Username': 'username', await state.clear()
'Имя': 'first_name', return
'Фамилия': 'last_name',
'Никнейм': 'nickname',
'Телефон': 'phone',
'Клубная карта': 'club_card_number',
'Зарегистрирован': 'is_registered',
'Код верификации': 'verification_code'
}
users_data = []
for row in rows[1:]: # Пропускаем заголовки
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
continue
user_dict = {'telegram_id': row[telegram_id_idx]}
for header_name, field_name in field_mapping.items():
try:
idx = headers.index(header_name)
if idx < len(row):
value = row[idx]
if field_name == 'is_registered':
user_dict[field_name] = value in ['Да', 'Yes', 'True', True, 1]
else:
user_dict[field_name] = value if value else None
except (ValueError, IndexError):
user_dict[field_name] = None
users_data.append(user_dict)
await status_msg.edit_text( await status_msg.edit_text(
f"📊 Найдено пользователей в файле: {len(users_data)}\n" f"📊 Найдено пользователей в файле: {len(users_data)}\n"
@@ -4702,26 +4723,39 @@ async def admin_import_users_process(message: Message, state: FSMContext):
# Импортируем пользователей # Импортируем пользователей
async with async_session_maker() as session: async with async_session_maker() as session:
from sqlalchemy import select
added_count = 0 added_count = 0
updated_count = 0 updated_count = 0
error_count = 0 error_count = 0
normalized_users = []
for user_data in users_data: for user_data in users_data:
telegram_id = user_data.get('telegram_id')
if not telegram_id:
error_count += 1
continue
try:
user_data['telegram_id'] = int(telegram_id)
except (ValueError, TypeError):
error_count += 1
continue
normalized_users.append(user_data)
existing_result = await session.execute(
select(User).where(User.telegram_id.in_([u['telegram_id'] for u in normalized_users]))
)
existing_by_tg_id = {
user.telegram_id: user
for user in existing_result.scalars().all()
}
for user_data in normalized_users:
try: try:
telegram_id = user_data.get('telegram_id') telegram_id = user_data.get('telegram_id')
if not telegram_id: existing_user = existing_by_tg_id.get(telegram_id)
error_count += 1
continue
# Преобразуем telegram_id в int если это строка
try:
telegram_id = int(telegram_id)
except (ValueError, TypeError):
error_count += 1
continue
# Ищем существующего пользователя
existing_user = await UserService.get_user_by_telegram_id(session, telegram_id)
if existing_user: if existing_user:
# Обновляем существующего # Обновляем существующего
@@ -4827,7 +4861,7 @@ async def admin_broadcast_menu(callback: CallbackQuery, state: FSMContext):
buttons = [ buttons = [
[InlineKeyboardButton(text="✉️ Создать рассылку", callback_data="admin_broadcast_start")], [InlineKeyboardButton(text="✉️ Создать рассылку", callback_data="admin_broadcast_start")],
[InlineKeyboardButton(text="📱 Управление каналами", callback_data="admin_broadcast_channels")], [InlineKeyboardButton(text="📱 Управление каналами", callback_data="admin_broadcast_channels")],
[InlineKeyboardButton(text="<EFBFBD> Статистика рассылок", callback_data="admin_broadcast_stats")], [InlineKeyboardButton(text="📊 Статистика рассылок", callback_data="admin_broadcast_stats")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")] [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
] ]

View File

@@ -1,4 +1,5 @@
"""Обработчики пользовательских сообщений в чате""" """Обработчики пользовательских сообщений в чате"""
import logging
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
@@ -21,8 +22,11 @@ from src.core.chat_services import (
from src.core.services import UserService from src.core.services import UserService
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.delivery import delivery_service
from src.utils.account_utils import parse_accounts_from_message from src.utils.account_utils import parse_accounts_from_message
logger = logging.getLogger(__name__)
class ChatStates(StatesGroup): class ChatStates(StatesGroup):
"""Состояния для работы в чате""" """Состояния для работы в чате"""
@@ -478,175 +482,195 @@ async def broadcast_message_with_scheduler(
return forwarded_ids, success_count, fail_count return forwarded_ids, success_count, fail_count
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]: async def _send_message_to_user(message: Message, user_telegram_id: int, retry: bool = True) -> Optional[int]:
""" """
Отправить сообщение конкретному пользователю. Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке. Возвращает message_id при успехе или None при ошибке.
""" """
try: result = await delivery_service.send_with_guard(
sent_msg = await message.copy_to(user_telegram_id) user_telegram_id,
return sent_msg.message_id lambda: message.copy_to(user_telegram_id),
except Exception as e: retry=retry,
print(f"Failed to send message to {user_telegram_id}: {e}") )
return None if result.success:
return result.telegram_message.message_id
logger.warning(
"Не удалось отправить сообщение %s: %s",
user_telegram_id,
result.status,
)
return None
async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]: async def _send_message_to_user_with_sender(
message: Message,
user_telegram_id: int,
sender_info: str,
retry: bool = True,
) -> Optional[int]:
""" """
Отправить сообщение обычному пользователю с информацией об отправителе. Отправить сообщение обычному пользователю с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке. Возвращает message_id при успехе или None при ошибке.
""" """
try: # Формируем текст с информацией об отправителе
# Формируем текст с информацией об отправителе header = f"📨 <b>{sender_info}:</b>\n\n"
header = f"📨 <b>{sender_info}:</b>\n\n"
async def send_message():
if message.text: if message.text:
# Текстовое сообщение return await message.bot.send_message(
sent_msg = await message.bot.send_message(
user_telegram_id, user_telegram_id,
header + message.text, header + message.text,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.photo: elif message.photo:
# Фото
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo( return await message.bot.send_photo(
user_telegram_id, user_telegram_id,
photo=message.photo[-1].file_id, photo=message.photo[-1].file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.video: elif message.video:
# Видео
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_video( return await message.bot.send_video(
user_telegram_id, user_telegram_id,
video=message.video.file_id, video=message.video.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.document: elif message.document:
# Документ
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_document( return await message.bot.send_document(
user_telegram_id, user_telegram_id,
document=message.document.file_id, document=message.document.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.animation: elif message.animation:
# GIF
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation( return await message.bot.send_animation(
user_telegram_id, user_telegram_id,
animation=message.animation.file_id, animation=message.animation.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.sticker: elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id) return await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
elif message.voice: elif message.voice:
# Голосовое сообщение return await message.bot.send_voice(
sent_msg = await message.bot.send_voice(
user_telegram_id, user_telegram_id,
voice=message.voice.file_id, voice=message.voice.file_id,
caption=header, caption=header,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.video_note: elif message.video_note:
# Видео-кружок
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id) return await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
except Exception as e: return await message.copy_to(user_telegram_id)
print(f"Failed to send message to {user_telegram_id}: {e}")
return None result = await delivery_service.send_with_guard(
user_telegram_id,
send_message,
retry=retry,
)
if result.success:
return result.telegram_message.message_id
logger.warning(
"Не удалось отправить сообщение %s: %s",
user_telegram_id,
result.status,
)
return None
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]: async def _send_message_to_admin_with_sender(
message: Message,
admin_telegram_id: int,
sender_info: str,
retry: bool = True,
) -> Optional[int]:
""" """
Отправить сообщение админу с информацией об отправителе. Отправить сообщение админу с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке. Возвращает message_id при успехе или None при ошибке.
""" """
try: # Формируем текст с информацией об отправителе
# Формируем текст с информацией об отправителе header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
async def send_message():
if message.text: if message.text:
# Текстовое сообщение return await message.bot.send_message(
sent_msg = await message.bot.send_message(
admin_telegram_id, admin_telegram_id,
header + message.text, header + message.text,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.photo: elif message.photo:
# Фото
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo( return await message.bot.send_photo(
admin_telegram_id, admin_telegram_id,
photo=message.photo[-1].file_id, photo=message.photo[-1].file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.video: elif message.video:
# Видео
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_video( return await message.bot.send_video(
admin_telegram_id, admin_telegram_id,
video=message.video.file_id, video=message.video.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.document: elif message.document:
# Документ
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_document( return await message.bot.send_document(
admin_telegram_id, admin_telegram_id,
document=message.document.file_id, document=message.document.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.animation: elif message.animation:
# GIF
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation( return await message.bot.send_animation(
admin_telegram_id, admin_telegram_id,
animation=message.animation.file_id, animation=message.animation.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.sticker: elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id) return await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
elif message.voice: elif message.voice:
# Голосовое сообщение return await message.bot.send_voice(
sent_msg = await message.bot.send_voice(
admin_telegram_id, admin_telegram_id,
voice=message.voice.file_id, voice=message.voice.file_id,
caption=header, caption=header,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.video_note: elif message.video_note:
# Видео-кружок
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id) return await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(admin_telegram_id)
return sent_msg.message_id await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
except Exception as e: return await message.copy_to(admin_telegram_id)
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
return None result = await delivery_service.send_with_guard(
admin_telegram_id,
send_message,
retry=retry,
)
if result.success:
return result.telegram_message.message_id
logger.warning(
"Не удалось отправить сообщение админу %s: %s",
admin_telegram_id,
result.status,
)
return None
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]: async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
@@ -656,7 +680,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
sent_msg = await message.forward(channel_id) sent_msg = await message.forward(channel_id)
return True, sent_msg.message_id return True, sent_msg.message_id
except Exception as e: except Exception as e:
print(f"Failed to forward message to channel {channel_id}: {e}") logger.warning(f"Не удалось переслать сообщение в канал {channel_id}: {e}")
return False, None return False, None
@@ -992,7 +1016,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
await message.answer("✅ Стикер переслан в канал") await message.answer("✅ Стикер переслан в канал")
@router.message(F.voice) @router.message(F.voice, StateFilter(ChatStates.in_chat))
async def handle_voice_message(message: Message): async def handle_voice_message(message: Message):
"""Обработчик голосовых сообщений - ЗАБЛОКИРОВАНО""" """Обработчик голосовых сообщений - ЗАБЛОКИРОВАНО"""
await message.answer( await message.answer(
@@ -1002,7 +1026,7 @@ async def handle_voice_message(message: Message):
return return
@router.message(F.audio) @router.message(F.audio, StateFilter(ChatStates.in_chat))
async def handle_audio_message(message: Message): async def handle_audio_message(message: Message):
"""Обработчик аудиофайлов (музыка, аудиозаписи) - ЗАБЛОКИРОВАНО""" """Обработчик аудиофайлов (музыка, аудиозаписи) - ЗАБЛОКИРОВАНО"""
await message.answer( await message.answer(

View File

@@ -7,6 +7,7 @@ from src.filters.case_insensitive import CaseInsensitiveCommand
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional from typing import Optional
from src.core.p2p_services import P2PMessageService from src.core.p2p_services import P2PMessageService
@@ -14,6 +15,7 @@ from src.core.services import UserService
from src.core.models import User from src.core.models import User
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.delivery import delivery_service
router = Router(name='p2p_chat_router') router = Router(name='p2p_chat_router')
@@ -124,9 +126,13 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
await callback.answer() await callback.answer()
async with async_session_maker() as session: async with async_session_maker() as session:
# Получаем всех зарегистрированных пользователей кроме себя result = await session.execute(
users = await UserService.get_all_users(session) select(User)
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered] .where(User.telegram_id != callback.from_user.id, User.is_registered == True)
.order_by(User.created_at.desc())
.limit(20)
)
users = result.scalars().all()
if not users: if not users:
await callback.message.edit_text("❌ Нет доступных пользователей для общения") await callback.message.edit_text("❌ Нет доступных пользователей для общения")
@@ -134,7 +140,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
# Создаём кнопки с пользователями (по 1 на строку) # Создаём кнопки с пользователями (по 1 на строку)
buttons = [] buttons = []
for user in users[:20]: # Ограничение 20 пользователей на странице for user in users:
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown" display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
if user.club_card_number: if user.club_card_number:
display_name += f" (карта: {user.club_card_number})" display_name += f" (карта: {user.club_card_number})"
@@ -411,49 +417,58 @@ async def handle_p2p_message(message: Message, state: FSMContext):
file_id = message.document.file_id file_id = message.document.file_id
text = message.caption text = message.caption
# Отправляем сообщение получателю async def send_to_recipient():
try:
if message_type == "text": if message_type == "text":
sent = await message.bot.send_message( return await message.bot.send_message(
recipient_telegram_id, recipient_telegram_id,
f"<b>{sender_name}</b>\n\n{text}", f"<b>{sender_name}</b>\n\n{text}",
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "photo": elif message_type == "photo":
sent = await message.bot.send_photo( return await message.bot.send_photo(
recipient_telegram_id, recipient_telegram_id,
photo=file_id, photo=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}" , caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "video": elif message_type == "video":
sent = await message.bot.send_video( return await message.bot.send_video(
recipient_telegram_id, recipient_telegram_id,
video=file_id, video=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}", caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "document": elif message_type == "document":
sent = await message.bot.send_document( return await message.bot.send_document(
recipient_telegram_id, recipient_telegram_id,
document=file_id, document=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}", caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML" parse_mode="HTML"
) )
# Сохраняем в БД return await message.copy_to(recipient_telegram_id)
await P2PMessageService.send_message(
session,
sender_id=sender.id,
recipient_id=recipient_id,
message_type=message_type,
text=text,
file_id=file_id,
sender_message_id=message.message_id,
recipient_message_id=sent.message_id
)
await message.answer("✅ Сообщение доставлено") delivery = await delivery_service.send_with_guard(
recipient_telegram_id,
send_to_recipient,
)
if not delivery.success:
if delivery.status in {"blocked_bot", "deactivated", "not_found", "chat_not_found"}:
await message.answer("❌ Получатель недоступен: он мог заблокировать бота")
else:
await message.answer("Не удалось доставить сообщение. Попробуйте позже")
return
except Exception as e: # Сохраняем в БД только реально доставленное сообщение.
await message.answer(f"Не удалось доставить сообщение: {e}") await P2PMessageService.send_message(
session,
sender_id=sender.id,
recipient_id=recipient_id,
message_type=message_type,
text=text,
file_id=file_id,
sender_message_id=message.message_id,
recipient_message_id=delivery.telegram_message.message_id
)
await message.answer("✅ Сообщение доставлено")

View File

@@ -13,6 +13,7 @@ from src.core.services import LotteryService
from src.core.models import User, Winner from src.core.models import User, Winner
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.permissions import admin_only from src.core.permissions import admin_only
from src.core.delivery import delivery_service
router = Router() router = Router()
@@ -276,18 +277,26 @@ async def redraw_lottery(message: Message):
)] )]
]) ])
try: delivery = await delivery_service.send_with_guard(
await message.bot.send_message( owner.telegram_id,
owner.telegram_id, lambda: message.bot.send_message(
notification_message, owner.telegram_id,
reply_markup=keyboard, notification_message,
parse_mode="Markdown" reply_markup=keyboard,
) parse_mode="Markdown"
),
)
if delivery.success:
new_winner.is_notified = True new_winner.is_notified = True
await session.commit() await session.commit()
except: else:
pass import logging
logging.getLogger(__name__).warning(
"Не удалось уведомить нового победителя %s: %s",
owner.telegram_id,
delivery.status,
)
# Формируем отчет для админа # Формируем отчет для админа
text = f"🔄 **Повторный розыгрыш завершен!**\n\n" text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
@@ -416,10 +425,12 @@ async def confirm_winner_callback(callback_query):
from aiogram import Bot from aiogram import Bot
from src.core.config import BOT_TOKEN from src.core.config import BOT_TOKEN
bot = Bot(token=BOT_TOKEN) bot = Bot(token=BOT_TOKEN)
await bot.send_message(admin_id, admin_text, parse_mode="Markdown") await delivery_service.send_with_guard(
admin_id,
lambda: bot.send_message(admin_id, admin_text, parse_mode="Markdown"),
)
except Exception as e: except Exception as e:
import logging import logging
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}") logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True) await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)

View File

@@ -5,9 +5,11 @@ from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Update, Message, CallbackQuery from aiogram.types import TelegramObject, Update, Message, CallbackQuery
import logging import logging
import time
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.activity_service import ActivityService from src.core.activity_service import ActivityService
from src.core.config import ACTIVITY_UPDATE_INTERVAL_SECONDS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -15,6 +17,8 @@ logger = logging.getLogger(__name__)
class ActivityMiddleware(BaseMiddleware): class ActivityMiddleware(BaseMiddleware):
"""Middleware для обновления last_activity при каждом взаимодействии""" """Middleware для обновления last_activity при каждом взаимодействии"""
_last_updates: Dict[int, float] = {}
async def __call__( async def __call__(
self, self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
@@ -34,19 +38,36 @@ class ActivityMiddleware(BaseMiddleware):
elif event.callback_query and event.callback_query.from_user: elif event.callback_query and event.callback_query.from_user:
telegram_id = event.callback_query.from_user.id telegram_id = event.callback_query.from_user.id
# Обновляем активность если есть telegram_id # Обновляем активность не чаще заданного интервала.
if telegram_id: if telegram_id and self._should_update(telegram_id):
try: try:
async with async_session_maker() as session: async with async_session_maker() as session:
# Обновляем активность reactivated = await ActivityService.update_activity_and_reactivate(session, telegram_id)
await ActivityService.update_user_activity(session, telegram_id) if reactivated is None:
self._last_updates.pop(telegram_id, None)
# Проверяем, не был ли пользователь заблокирован за неактивность return await handler(event, data)
# Если был - реактивируем if reactivated:
await ActivityService.reactivate_user(session, telegram_id) logger.info(f"Пользователь {telegram_id} реактивирован")
except Exception as e: except Exception as e:
self._last_updates.pop(telegram_id, None)
logger.error(f"Ошибка в ActivityMiddleware для пользователя {telegram_id}: {e}") logger.error(f"Ошибка в ActivityMiddleware для пользователя {telegram_id}: {e}")
# Вызываем следующий обработчик # Вызываем следующий обработчик
return await handler(event, data) return await handler(event, data)
@classmethod
def _should_update(cls, telegram_id: int) -> bool:
now = time.monotonic()
last_update = cls._last_updates.get(telegram_id)
if last_update and now - last_update < ACTIVITY_UPDATE_INTERVAL_SECONDS:
return False
cls._last_updates[telegram_id] = now
if len(cls._last_updates) > 10000:
cutoff = now - ACTIVITY_UPDATE_INTERVAL_SECONDS
cls._last_updates = {
user_id: updated_at
for user_id, updated_at in cls._last_updates.items()
if updated_at >= cutoff
}
return True

View File

@@ -11,6 +11,7 @@ from ..core.models import Winner, User
from ..core.services import LotteryService from ..core.services import LotteryService
from ..core.registration_services import AccountService, WinnerNotificationService from ..core.registration_services import AccountService, WinnerNotificationService
from ..core.config import ADMIN_IDS from ..core.config import ADMIN_IDS
from ..core.delivery import delivery_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -79,19 +80,28 @@ async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int)
)] )]
]) ])
# Отправляем уведомление с кнопкой delivery = await delivery_service.send_with_guard(
await bot.send_message(
owner.telegram_id, owner.telegram_id,
message, lambda: bot.send_message(
reply_markup=keyboard, owner.telegram_id,
parse_mode="Markdown" message,
reply_markup=keyboard,
parse_mode="Markdown"
),
) )
# Отмечаем, что уведомление отправлено if delivery.success:
winner.is_notified = True # Отмечаем, что уведомление отправлено
await session.commit() winner.is_notified = True
await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}") logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
else:
logger.warning(
"⚠️ Уведомление победителю %s не отправлено: %s",
owner.telegram_id,
delivery.status,
)
else: else:
logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id") logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id")
@@ -118,16 +128,26 @@ async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int)
)] )]
]) ])
await bot.send_message( delivery = await delivery_service.send_with_guard(
user.telegram_id, user.telegram_id,
message, lambda: bot.send_message(
reply_markup=keyboard user.telegram_id,
message,
reply_markup=keyboard
),
) )
winner.is_notified = True if delivery.success:
await session.commit() winner.is_notified = True
await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})") logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
else:
logger.warning(
"⚠️ Уведомление победителю %s не отправлено: %s",
user.telegram_id,
delivery.status,
)
else: else:
logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id") logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id")

View File

@@ -152,17 +152,18 @@ class AsyncTaskManager:
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
# Получаем семафоры try:
async with self.worker_semaphore: # Получаем семафоры
user_semaphore = self._get_user_semaphore(task.user_id) async with self.worker_semaphore:
if user_semaphore is not None: user_semaphore = self._get_user_semaphore(task.user_id)
async with user_semaphore: if user_semaphore is not None:
async with user_semaphore:
await self._execute_task(worker_name, task)
else:
await self._execute_task(worker_name, task) await self._execute_task(worker_name, task)
else: finally:
await self._execute_task(worker_name, task) # Отмечаем задачу как обработанную даже при ошибке выполнения.
self.task_queue.task_done()
# Отмечаем задачу как выполненную
self.task_queue.task_done()
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug(f"Воркер {worker_name} отменён") logger.debug(f"Воркер {worker_name} отменён")

View File

@@ -26,7 +26,7 @@ async def test_comprehensive_features():
for i in range(5): for i in range(5):
# Генерируем уникальные данные # Генерируем уникальные данные
unique_id = random.randint(10000000, 99999999) unique_id = random.randint(10000000, 99999999)
account = f"{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}" account = "-".join(f"{random.randint(10,99)}" for _ in range(7))
user = await UserService.get_or_create_user( user = await UserService.get_or_create_user(
session, session,
@@ -52,7 +52,7 @@ async def test_comprehensive_features():
title="Тест массовых операций", title="Тест массовых операций",
description="Розыгрыш для тестирования массовых операций", description="Розыгрыш для тестирования массовых операций",
prizes=["Приз 1", "Приз 2"], prizes=["Приз 1", "Приз 2"],
creator_id=1 creator_id=test_users[0].id
) )
print(f"✅ Розыгрыш создан: {lottery.title}") print(f"✅ Розыгрыш создан: {lottery.title}")

View File

@@ -27,7 +27,7 @@ async def test_basic_functionality():
# 1. Тест создания пользователя с номером счёта # 1. Тест создания пользователя с номером счёта
print("\n1. 👤 Создание пользователя с номером счёта:") print("\n1. 👤 Создание пользователя с номером счёта:")
test_account = "12-34-56-78-90-12-34-56" test_account = "12-34-56-78-90-12-34"
user = await UserService.get_or_create_user( user = await UserService.get_or_create_user(
session, session,
telegram_id=999999999, telegram_id=999999999,
@@ -49,7 +49,7 @@ async def test_basic_functionality():
print("\n2. 📋 Тестирование валидации номеров:") print("\n2. 📋 Тестирование валидации номеров:")
test_cases = [ test_cases = [
("12-34-56-78-90-12-34-56", True), ("12-34-56-78-90-12-34", True),
("invalid-number", False), ("invalid-number", False),
("12345678901234567890", False) ("12345678901234567890", False)
] ]
@@ -84,7 +84,7 @@ async def test_basic_functionality():
title="Тест отображения", title="Тест отображения",
description="Тестовый розыгрыш", description="Тестовый розыгрыш",
prizes=["Приз 1"], prizes=["Приз 1"],
creator_id=1 creator_id=user.id
) )
display_types = ['username', 'chat_id', 'account_number'] display_types = ['username', 'chat_id', 'account_number']

View File

@@ -20,7 +20,7 @@ async def test_features():
async with async_session_maker() as session: async with async_session_maker() as session:
# Генерируем уникальные тестовые данные # Генерируем уникальные тестовые данные
unique_id = random.randint(1000000, 9999999) unique_id = random.randint(1000000, 9999999)
test_account = f"{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}" test_account = "-".join(f"{random.randint(10, 99)}" for _ in range(7))
print(f"🆔 Используем уникальный ID: {unique_id}") print(f"🆔 Используем уникальный ID: {unique_id}")
print(f"🔢 Используем номер счёта: {test_account}") print(f"🔢 Используем номер счёта: {test_account}")
@@ -44,9 +44,7 @@ async def test_features():
if success: if success:
print(f"✅ Номер счёта {test_account} установлен") print(f"✅ Номер счёта {test_account} установлен")
# Обновляем данные пользователя print(f"✅ Подтверждено: {test_account}")
await session.refresh(user)
print(f"✅ Подтверждено: {user.account_number}")
else: else:
print(f"Не удалось установить номер счёта") print(f"Не удалось установить номер счёта")
return return
@@ -72,7 +70,7 @@ async def test_features():
title="Тест отображения", title="Тест отображения",
description="Тестовый розыгрыш", description="Тестовый розыгрыш",
prizes=["Приз 1"], prizes=["Приз 1"],
creator_id=1 creator_id=user.id
) )
# Тестируем все типы отображения # Тестируем все типы отображения

View File

@@ -0,0 +1,130 @@
import os
import sys
import pytest
from aiogram.exceptions import TelegramForbiddenError
from sqlalchemy import delete, select
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import src.core.models # noqa: F401 - register SQLAlchemy models before init_db
from src.core.activity_service import ActivityService
from src.core.database import async_session_maker, init_db
from src.core.delivery import DeliveryService
from src.core.models import BlockedUser
class FakeTelegramMessage:
message_id = 123
async def _cleanup_blocked_user(telegram_id: int):
async with async_session_maker() as session:
await session.execute(
delete(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
)
await session.commit()
async def _get_blocked_user(telegram_id: int):
async with async_session_maker() as session:
result = await session.execute(
select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
)
return result.scalar_one_or_none()
@pytest.mark.asyncio
async def test_delivery_guard_skips_known_blocked_user():
await init_db()
telegram_id = 880000001
await _cleanup_blocked_user(telegram_id)
async with async_session_maker() as session:
session.add(
BlockedUser(
telegram_id=telegram_id,
error_type="blocked_bot",
error_message="Forbidden: bot was blocked by the user",
is_active=True,
)
)
await session.commit()
called = False
async def send_call():
nonlocal called
called = True
return FakeTelegramMessage()
result = await DeliveryService().send_with_guard(telegram_id, send_call)
assert result.success is False
assert result.skipped is True
assert result.status == "blocked_bot"
assert called is False
await _cleanup_blocked_user(telegram_id)
@pytest.mark.asyncio
async def test_delivery_guard_marks_user_blocked_on_forbidden():
await init_db()
telegram_id = 880000002
await _cleanup_blocked_user(telegram_id)
async def send_call():
raise TelegramForbiddenError(
method=None,
message="Forbidden: bot was blocked by the user",
)
result = await DeliveryService().send_with_guard(telegram_id, send_call)
blocked_user = await _get_blocked_user(telegram_id)
assert result.success is False
assert result.status == "blocked_bot"
assert blocked_user is not None
assert blocked_user.is_active is True
assert blocked_user.error_type == "blocked_bot"
await _cleanup_blocked_user(telegram_id)
def test_delivery_guard_does_not_classify_parse_errors_as_blocked():
service = DeliveryService()
assert service.classify_bad_request(Exception("Bad Request: can't parse entities")) is None
@pytest.mark.asyncio
async def test_activity_reactivates_delivery_after_user_interaction():
await init_db()
telegram_id = 880000003
await _cleanup_blocked_user(telegram_id)
async with async_session_maker() as session:
session.add(
BlockedUser(
telegram_id=telegram_id,
error_type="blocked_bot",
error_message="Forbidden: bot was blocked by the user",
is_active=True,
)
)
await session.commit()
async with async_session_maker() as session:
reactivated = await ActivityService.update_activity_and_reactivate(
session,
telegram_id,
)
blocked_user = await _get_blocked_user(telegram_id)
assert reactivated is True
assert blocked_user is not None
assert blocked_user.is_active is False
await _cleanup_blocked_user(telegram_id)

View File

@@ -30,14 +30,14 @@ async def test_account_functionality():
'username': 'client1', 'username': 'client1',
'first_name': 'Клиент', 'first_name': 'Клиент',
'last_name': 'Первый', 'last_name': 'Первый',
'account': '12-34-56-78-90-12-34-56' 'account': '12-34-56-78-90-12-34'
}, },
{ {
'telegram_id': 777000002, 'telegram_id': 777000002,
'username': 'client2', 'username': 'client2',
'first_name': 'Клиент', 'first_name': 'Клиент',
'last_name': 'Второй', 'last_name': 'Второй',
'account': '11-22-33-44-55-66-77-88' 'account': '11-22-33-44-55-66-77'
}, },
{ {
'telegram_id': 777000003, 'telegram_id': 777000003,
@@ -78,9 +78,9 @@ async def test_account_functionality():
print("\n📋 Тестирование валидации номеров счетов:") print("\n📋 Тестирование валидации номеров счетов:")
test_numbers = [ test_numbers = [
("12-34-56-78-90-12-34-56", True), # Корректный ("12-34-56-78-90-12-34", True), # Корректный
("12345678901234567890123456", False), # Без дефисов ("12345678901234", False), # Без дефисов
("12-34-56-78-90-12-34", False), # Короткий ("12-34-56-78-90-12", False), # Короткий
("12-34-56-78-90-12-34-56-78", False), # Длинный ("12-34-56-78-90-12-34-56-78", False), # Длинный
("ab-cd-ef-gh-ij-kl-mn-op", False), # Буквы ("ab-cd-ef-gh-ij-kl-mn-op", False), # Буквы
("", False) # Пустой ("", False) # Пустой
@@ -93,7 +93,7 @@ async def test_account_functionality():
# Тест маскирования # Тест маскирования
print("\n🎭 Тестирование маскирования номеров:") print("\n🎭 Тестирование маскирования номеров:")
test_account = "12-34-56-78-90-12-34-56" test_account = "12-34-56-78-90-12-34"
for digits in [4, 6, 8]: for digits in [4, 6, 8]:
masked = mask_account_number(test_account, show_last_digits=digits) masked = mask_account_number(test_account, show_last_digits=digits)
print(f"Показать последние {digits} цифр: {masked}") print(f"Показать последние {digits} цифр: {masked}")
@@ -103,7 +103,7 @@ async def test_account_functionality():
async with async_session_maker() as session: async with async_session_maker() as session:
# Полный номер # Полный номер
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34-56") user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34")
if user: if user:
print(f"✅ Найден пользователь по полному номеру: {user.first_name}") print(f"✅ Найден пользователь по полному номеру: {user.first_name}")
@@ -123,18 +123,21 @@ async def test_display_types():
print("=" * 50) print("=" * 50)
async with async_session_maker() as session: async with async_session_maker() as session:
# Получаем пользователя со счётом
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34")
if not user:
print("Не найден пользователь со счетом для теста отображения")
return
# Создаём розыгрыш # Создаём розыгрыш
lottery = await LotteryService.create_lottery( lottery = await LotteryService.create_lottery(
session, session,
title="Тест отображения победителей", title="Тест отображения победителей",
description="Розыгрыш для тестирования различных типов отображения", description="Розыгрыш для тестирования различных типов отображения",
prizes=["Первый приз", "Второй приз"], prizes=["Первый приз", "Второй приз"],
creator_id=1 creator_id=user.id
) )
# Получаем пользователя со счётом
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34-56")
if user and lottery: if user and lottery:
print(f"👤 Тестируем отображение для пользователя: {user.first_name}") print(f"👤 Тестируем отображение для пользователя: {user.first_name}")
print(f"💳 Номер счёта: {user.account_number}") print(f"💳 Номер счёта: {user.account_number}")