feat: Система автоматического подтверждения выигрышей с поддержкой множественных счетов
Some checks reported errors
continuous-integration/drone/push Build encountered an error

Основные изменения:

 Новые функции:
- Система регистрации пользователей с множественными счетами
- Автоматическое подтверждение выигрышей через inline-кнопки
- Механизм переигровки для неподтвержденных выигрышей (24 часа)
- Подтверждение на уровне счетов (каждый счет подтверждается отдельно)
- Скрипт полной очистки базы данных

🔧 Технические улучшения:
- Исправлена ошибка MissingGreenlet при lazy loading (добавлен joinedload/selectinload)
- Добавлено поле claimed_at для отслеживания времени подтверждения
- Пакетное добавление счетов с выбором розыгрыша
- Проверка владения конкретным счетом при подтверждении

📚 Документация:
- docs/AUTO_CONFIRM_SYSTEM.md - Полная документация системы подтверждения
- docs/ACCOUNT_BASED_CONFIRMATION.md - Подтверждение на уровне счетов
- docs/REGISTRATION_SYSTEM.md - Система регистрации
- docs/ADMIN_COMMANDS.md - Команды администратора
- docs/CLEAR_DATABASE.md - Очистка БД
- docs/QUICK_GUIDE.md - Быстрое начало
- docs/UPDATE_LOG.md - Журнал обновлений

🗄️ База данных:
- Миграция 003: Таблицы accounts, winner_verifications
- Миграция 004: Поле claimed_at в таблице winners
- Скрипт scripts/clear_database.py для полной очистки

🎮 Новые команды:
Админские:
- /check_unclaimed <lottery_id> - Проверка неподтвержденных выигрышей
- /redraw <lottery_id> - Повторный розыгрыш
- /add_accounts - Пакетное добавление счетов
- /list_accounts <telegram_id> - Список счетов пользователя

Пользовательские:
- /register - Регистрация с вводом данных
- /my_account - Просмотр своих счетов
- Callback confirm_win_{id} - Подтверждение выигрыша

🛠️ Makefile:
- make clear-db - Очистка всех данных из БД (с подтверждением)

🔒 Безопасность:
- Проверка владения счетом при подтверждении
- Защита от подтверждения чужих счетов
- Независимое подтверждение каждого выигрышного счета

📊 Логика работы:
1. Пользователь регистрируется и добавляет счета
2. Счета участвуют в розыгрыше
3. Победители получают уведомление с кнопкой подтверждения
4. Каждый счет подтверждается отдельно (24 часа на подтверждение)
5. Неподтвержденные выигрыши переигрываются через /redraw
This commit is contained in:
2025-11-16 14:01:30 +09:00
parent 31c4c5382a
commit 505d26f0e9
21 changed files with 4217 additions and 68 deletions

View File

@@ -20,6 +20,7 @@ help:
@echo " make stats - Показать статистику"
@echo " make demo-admin - Демонстрация админ-панели"
@echo " make test-admin - Тестирование улучшений админки"
@echo " make clear-db - ⚠️ УДАЛИТЬ ВСЕ ДАННЫЕ из БД"
@echo ""
@echo "Быстрый старт с PostgreSQL:"
@echo " 1. cp .env.example .env"
@@ -102,6 +103,23 @@ test-admin:
@echo "🧪 Тестирование новых функций админ-панели..."
. .venv/bin/activate && python tests/test_admin_improvements.py
# ⚠️ ОПАСНО: Полная очистка базы данных
clear-db:
@echo "⚠️ ВНИМАНИЕ! Это удалит ВСЕ данные из базы данных!"
@echo " - Все пользователи"
@echo " - Все розыгрыши"
@echo " - Все счета"
@echo " - Все участия"
@echo " - Всех победителей"
@echo ""
@read -p "Вы уверены? Введите 'yes' для подтверждения: " confirm; \
if [ "$$confirm" = "yes" ]; then \
echo "🗑️ Очистка базы данных..."; \
. .venv/bin/activate && python scripts/clear_database.py; \
else \
echo "❌ Отменено"; \
fi
# Очистка
clean:
@echo "🧹 Очистка временных файлов..."

View File

@@ -0,0 +1,279 @@
# Подтверждение выигрышей по счетам
## 🎯 Концепция
Система подтверждения выигрышей работает **на уровне счетов**, а не пользователей.
### Основные принципы:
1. **Один пользователь = Много счетов**
- У клиента может быть несколько счетов (11-22-33..., 44-55-66..., и т.д.)
- Каждый счет участвует в розыгрыше независимо
2. **Один счет = Один выигрыш = Одно подтверждение**
- Если счет `11-22-33-44-55-66-77` выиграл 1 место - требуется подтверждение
- Если счет `44-55-66-77-88-99-00` выиграл 3 место - требуется отдельное подтверждение
- Даже если оба счета принадлежат одному клиенту
3. **Независимое подтверждение**
- Каждый выигрышный счет подтверждается отдельной кнопкой
- Подтверждение одного счета не влияет на другие
- У каждого счета свой 24-часовой лимит
## 📱 Как это работает для пользователя
### Сценарий: У клиента 3 счета, выиграли 2
**Счета клиента:**
- `11-22-33-44-55-66-77` ✅ Выиграл 1 место (iPhone 15)
- `22-33-44-55-66-77-88`Не выиграл
- `33-44-55-66-77-88-99` ✅ Выиграл 3 место (AirPods Pro)
**Клиент получает 2 сообщения:**
### Сообщение 1:
```
🎉 Поздравляем! Ваш счет выиграл!
🎯 Розыгрыш: Новогодний розыгрыш
🏆 Место: 1
🎁 Приз: iPhone 15
💳 Выигрышный счет: 11-22-33-44-55-66-77
У вас есть 24 часа для подтверждения!
Нажмите кнопку ниже, чтобы подтвердить получение приза по этому счету.
Если вы не подтвердите в течение 24 часов, приз будет разыгран заново.
Если у вас несколько выигрышных счетов, подтвердите каждый из них отдельно.
[✅ Подтвердить счет 11-22-33-44-55-66-77]
[📞 Связаться с администратором]
```
### Сообщение 2:
```
🎉 Поздравляем! Ваш счет выиграл!
🎯 Розыгрыш: Новогодний розыгрыш
🏆 Место: 3
🎁 Приз: AirPods Pro
💳 Выигрышный счет: 33-44-55-66-77-88-99
У вас есть 24 часа для подтверждения!
[✅ Подтвердить счет 33-44-55-66-77-88-99]
[📞 Связаться с администратором]
```
### Действия клиента:
1. **Нажимает первую кнопку** → Счет `11-22-33-44-55-66-77` подтвержден ✅
2. **Нажимает вторую кнопку** → Счет `33-44-55-66-77-88-99` подтвержден ✅
## 🔒 Безопасность
### Проверка владения счетом
При нажатии кнопки подтверждения система проверяет:
```python
# Получаем владельца КОНКРЕТНОГО счета
owner = await AccountService.get_account_owner(session, winner.account_number)
# Проверяем что текущий пользователь - владелец ЭТОГО счета
if not owner or owner.telegram_id != callback.from_user.id:
await callback.answer(
f"❌ Счет {winner.account_number} вам не принадлежит",
show_alert=True
)
return
```
### Что НЕ может сделать пользователь:
❌ Подтвердить чужой счет
❌ Подтвердить счет, который ему не принадлежит
❌ Подтвердить один счет дважды
### Что может сделать пользователь:
✅ Подтвердить только свои счета
✅ Подтвердить каждый свой выигрышный счет отдельно
✅ Видеть номер счета на каждой кнопке
## 🎊 После подтверждения
### Сообщение пользователю:
```
✅ Выигрыш успешно подтвержден!
🎯 Розыгрыш: Новогодний розыгрыш
🏆 Место: 1
🎁 Приз: iPhone 15
💳 Счет: 11-22-33-44-55-66-77
🎊 Поздравляем! Администратор свяжется с вами
для передачи приза в ближайшее время.
Спасибо за участие!
```
### Уведомление администратору:
```
✅ Победитель подтвердил получение приза!
🎯 Розыгрыш: Новогодний розыгрыш
🏆 Место: 1
🎁 Приз: iPhone 15
💳 Подтвержденный счет: 11-22-33-44-55-66-77
👤 Владелец: Иван Петров (@ivan)
🎫 Клубная карта: 2223
📱 Телефон: +7 900 123-45-67
```
## 📊 База данных
### Таблица `winners`
Каждая запись = один выигрышный счет:
```sql
id | lottery_id | account_number | place | prize | is_claimed | claimed_at
---|------------|-----------------------|-------|------------|------------|------------------
1 | 5 | 11-22-33-44-55-66-77 | 1 | iPhone 15 | TRUE | 2025-11-16 14:00
2 | 5 | 33-44-55-66-77-88-99 | 3 | AirPods | TRUE | 2025-11-16 14:05
3 | 5 | 55-66-77-88-99-00-11 | 2 | MacBook | FALSE | NULL
```
### Ключевые поля:
- `account_number` - конкретный выигрышный счет
- `is_claimed` - подтвержден ли ЭТОТ счет
- `claimed_at` - когда ЭТОТ счет был подтвержден
## 🔄 Повторный розыгрыш
Если счет не подтвержден в течение 24 часов:
1. **Админ проверяет**: `/check_unclaimed 5`
```
⚠️ Неподтвержденные выигрыши:
🏆 2 место - MacBook
💳 55-66-77-88-99-00-11
⏰ Прошло: 26 часов
```
2. **Админ переигрывает**: `/redraw 5`
- Удаляется Winner с `account_number = 55-66-77-88-99-00-11`
- Выбирается новый случайный счет
- Новому владельцу счета отправляется уведомление
- Новый владелец получает свои 24 часа
## 💡 Преимущества подхода
### ✅ Для пользователей:
1. **Понятность** - видят конкретный номер счета на кнопке
2. **Контроль** - могут подтверждать счета независимо
3. **Гибкость** - один счет подтвердил, другой нет (если забыл)
### ✅ Для администраторов:
1. **Точность** - знают какой именно счет подтвержден
2. **Прозрачность** - видят все действия по каждому счету
3. **Справедливость** - переиграть можно конкретный неподтвержденный счет
### ✅ Для системы:
1. **Масштабируемость** - неограниченное количество счетов у одного пользователя
2. **Независимость** - каждый счет живет своей жизнью
3. **Целостность** - нет конфликтов между разными выигрышами
## 🔍 Примеры использования
### Пример 1: Один пользователь, два выигрыша
```
Клиент: Иван Петров (КК: 2223)
Счета:
- 11-22-33-44-55-66-77 → Выиграл 1 место ✅ Подтвержден
- 22-33-44-55-66-77-88 → Выиграл 3 место ✅ Подтвержден
Результат: Иван получит 2 приза
```
### Пример 2: Частичное подтверждение
```
Клиент: Мария Сидорова (КК: 3334)
Счета:
- 33-44-55-66-77-88-99 → Выиграл 2 место ✅ Подтвержден
- 44-55-66-77-88-99-00 → Выиграл 4 место ❌ Не подтвержден (> 24ч)
Результат:
- Мария получит приз за 2 место
- 4 место будет переиграно
```
### Пример 3: Полная неявка
```
Клиент: Петр Иванов (КК: 5556)
Счета:
- 55-66-77-88-99-00-11 → Выиграл 1 место ❌ Не подтвержден
- 66-77-88-99-00-11-22 → Выиграл 2 место ❌ Не подтвержден
Результат: Оба места будут переиграны отдельно
```
## 📝 Технические детали
### Callback данные
```python
# Формат: confirm_win_{winner_id}
callback_data = f"confirm_win_{winner.id}"
# winner.id - уникальный ID записи в таблице winners
# Каждый счет-выигрыш имеет свой winner_id
```
### Проверка при подтверждении
```python
# 1. Получаем winner по ID
winner = await session.get(Winner, winner_id)
# 2. Проверяем что счет принадлежит пользователю
owner = await AccountService.get_account_owner(session, winner.account_number)
if owner.telegram_id != current_user_id:
return "Не ваш счет"
# 3. Подтверждаем ЭТОТ счет
winner.is_claimed = True
winner.claimed_at = datetime.now(timezone.utc)
await session.commit()
```
## 🎓 Выводы
Подход "счет = выигрыш" обеспечивает:
- 🎯 **Точность** - подтверждается конкретный счет, а не абстрактный выигрыш
- 🔒 **Безопасность** - только владелец счета может подтвердить
- 📊 **Масштабируемость** - неограниченное количество счетов и выигрышей
- 👥 **Справедливость** - каждый счет обрабатывается независимо
- 💡 **Прозрачность** - всегда понятно какой именно счет подтверждается
---
## 📞 См. также
- `AUTO_CONFIRM_SYSTEM.md` - Полная документация системы подтверждения
- `REGISTRATION_SYSTEM.md` - Система регистрации счетов
- `ADMIN_GUIDE.md` - Руководство администратора

318
docs/ADMIN_COMMANDS.md Normal file
View File

@@ -0,0 +1,318 @@
# Админские команды - Руководство
## 🎯 Управление счетами
### `/add_account` - Добавить счет пользователю
Привязывает счет к зарегистрированному пользователю по клубной карте.
**Формат:**
```
/add_account <club_card> <account_number>
```
**Пример:**
```
/add_account 2223 11-22-33-44-55-66-77
```
**Что происходит:**
- Система проверяет существование пользователя с указанной клубной картой
- Создает новую запись счета в таблице `accounts`
- Отправляет уведомление владельцу о добавлении счета
- Счет становится активным и может участвовать в розыгрышах
**Возможные ошибки:**
- "Пользователь с клубной картой X не найден" - пользователь не зарегистрирован
- "Счет уже существует" - этот номер счета уже привязан к другому пользователю
---
### `/remove_account` - Деактивировать счет
Делает счет неактивным (не удаляет из БД).
**Формат:**
```
/remove_account <account_number>
```
**Пример:**
```
/remove_account 11-22-33-44-55-66-77
```
**Что происходит:**
- Устанавливает флаг `is_active = False`
- Счет остается в БД, но не может участвовать в новых розыгрышах
- История участия сохраняется
---
## 🏆 Управление выигрышами
### `/verify_winner` - Подтвердить выигрыш
Подтверждает выигрыш по коду верификации пользователя.
**Формат:**
```
/verify_winner <verification_code> <lottery_id>
```
**Пример:**
```
/verify_winner AB12CD34 1
```
**Процесс верификации:**
1. Пользователь сообщает администратору свой код верификации
2. Администратор проверяет, что пользователь является победителем указанного розыгрыша
3. Система устанавливает флаг `is_claimed = True` для выигрыша
4. Отправляется уведомление победителю о подтверждении
**Что происходит:**
- Поиск пользователя по коду верификации
- Проверка наличия выигрыша в указанном розыгрыше
- Установка флага `is_claimed = True`
- Отправка подтверждающего сообщения победителю
**Возможные ошибки:**
- "Розыгрыш не найден" - неверный lottery_id
- "Выигрыш не найден" - неверный код или пользователь не победитель
- "Выигрыш уже был подтвержден" - повторная попытка подтверждения
---
### `/winner_status` - Статус победителей
Показывает всех победителей розыгрыша и их статус подтверждения.
**Формат:**
```
/winner_status <lottery_id>
```
**Пример:**
```
/winner_status 1
```
**Отображаемая информация:**
- 🏆 Место и приз
- 👤 Имя и клубная карта победителя
- 💳 Номер счета (если участвовал через счет)
- ✅ Статус подтверждения (подтвержден / ожидает)
- 📨 Статус уведомления (отправлено / нет)
**Статусы:**
- ✅ - Выигрыш подтвержден (`is_claimed = True`)
- ⏳ - Ожидает подтверждения (`is_claimed = False`)
- 📨 - Уведомление отправлено (`is_notified = True`)
- 📭 - Уведомление не отправлено (`is_notified = False`)
---
## 👤 Информация о пользователе
### `/user_info` - Информация о пользователе
Показывает полную информацию о пользователе по клубной карте.
**Формат:**
```
/user_info <club_card>
```
**Пример:**
```
/user_info 2223
```
**Отображаемая информация:**
- 🎫 Клубная карта
- 👤 Имя и Telegram username
- 📞 Телефон (если указан)
- 🔑 Код верификации
- 📅 Дата регистрации
- 💳 Список всех счетов (активные и неактивные)
- 🏆 История выигрышей
---
## 🔄 Автоматические уведомления
При проведении розыгрыша (`/conduct` через кнопку) система автоматически:
1. **Определяет владельцев счетов-победителей**
- Ищет запись в таблице `accounts`
- Находит владельца через `owner_id`
2. **Создает токен верификации**
- Генерирует уникальный токен для каждого выигрыша
- Устанавливает срок действия 24 часа
- Сохраняет в таблице `winner_verifications`
3. **Отправляет уведомление победителю**
```
🎉 Поздравляем! Ваш счет выиграл!
🎯 Розыгрыш: Название розыгрыша
🏆 Место: 1
🎁 Приз: Приз первого места
💳 Счет: 11-22-33-44-55-66-77
🔑 Ваш код верификации: AB12CD34
Для получения приза свяжитесь с администратором
и предоставьте этот код.
```
4. **Устанавливает флаг уведомления**
- `is_notified = True` в таблице `winners`
---
## 🔐 Система верификации
### Как это работает:
1. **При регистрации пользователя:**
- Генерируется уникальный 8-символьный код (например, `AB12CD34`)
- Сохраняется в `users.verification_code`
- Пользователь может посмотреть свой код через `/my_code`
2. **При выигрыше:**
- Владельцу счета отправляется сообщение с его кодом верификации
- Создается токен в таблице `winner_verifications`
3. **При подтверждении выигрыша:**
- Администратор запрашивает код у победителя
- Вводит команду `/verify_winner AB12CD34 1`
- Система проверяет соответствие кода и наличие выигрыша
- Устанавливает `is_claimed = True`
### Преимущества:
- 🔒 **Безопасность**: Только владелец знает свой код
- ✅ **Проверка**: Невозможно подтвердить чужой выигрыш
- 📊 **Отслеживание**: Полная история подтверждений
- ⏱️ **Контроль**: Токены имеют срок действия
---
## 📊 Типичные сценарии
### Сценарий 1: Новый пользователь с двумя счетами
```
1. Пользователь: /start → "Зарегистрироваться"
2. Пользователь: Вводит клубную карту "2223"
3. Пользователь: Вводит телефон или пропускает
4. Система: Показывает код верификации "AB12CD34"
5. Админ: /add_account 2223 11-22-33-44-55-66-77
6. Админ: /add_account 2223 88-99-00-11-22-33-44
7. Пользователь: /my_accounts
→ Видит оба счета
```
### Сценарий 2: Проведение розыгрыша и подтверждение
```
1. Админ: Создает розыгрыш через интерфейс
2. Счета участвуют автоматически
3. Админ: Нажимает "Провести розыгрыш"
4. Система: Автоматически отправляет уведомления:
"🎉 Поздравляем! Ваш счет выиграл!
💳 Счет: 11-22-33-44-55-66-77
🔑 Ваш код верификации: AB12CD34"
5. Победитель: Связывается с админом, называет код "AB12CD34"
6. Админ: /verify_winner AB12CD34 1
7. Система: Подтверждает выигрыш, отправляет уведомление победителю
```
### Сценарий 3: Проверка статуса всех победителей
```
1. Админ: /winner_status 1
Результат:
🏆 Победители розыгрыша 'Новогодний розыгрыш':
✅ 1 место - Главный приз
👤 Иван (КК: 2223)
💳 11-22-33-44-55-66-77
✅ Подтвержден
⏳ 2 место - Второй приз
👤 Петр (КК: 3334)
💳 22-33-44-55-66-77-88
⏳ Ожидает подтверждения
```
---
## 🛠️ Техническая информация
### Таблицы базы данных:
**accounts** - Счета пользователей
- `account_number` - Номер счета (формат XX-XX-XX-XX-XX-XX-XX)
- `owner_id` - ID владельца (FK → users.id)
- `is_active` - Активен ли счет
- `created_at` - Дата создания
**winner_verifications** - Токены верификации выигрышей
- `winner_id` - ID выигрыша (FK → winners.id)
- `verification_token` - Уникальный токен
- `is_verified` - Подтвержден ли
- `verified_at` - Время подтверждения
- `expires_at` - Срок действия (24 часа)
**winners** - Победители (расширенная)
- Добавлены поля: `is_notified`, `is_claimed`
**users** - Пользователи (расширенная)
- Добавлены поля: `club_card_number`, `phone`, `is_registered`, `verification_code`
---
## 📝 Полезные команды для пользователей
- `/start` - Главное меню / регистрация
- `/my_code` - Показать свой код верификации
- `/my_accounts` - Список моих счетов
---
## ⚠️ Важные замечания
1. **Код верификации** - строго конфиденциальная информация
2. **Один счет** = один владелец (нельзя передать счет другому)
3. **Деактивация счета** - не удаляет историю участия
4. **Токены верификации** - действуют 24 часа
5. **Уведомления** - отправляются автоматически только если пользователь зарегистрирован
---
## 🆘 Решение проблем
**Пользователь не получил уведомление о выигрыше:**
- Проверить `/winner_status <lottery_id>` - должна быть отметка 📨
- Возможно, пользователь заблокировал бота
- Проверить telegram_id пользователя через `/user_info`
**Не получается подтвердить выигрыш:**
- Проверить код верификации: `/user_info <club_card>`
- Убедиться что lottery_id верный
- Проверить что выигрыш еще не подтвержден
**Счет не добавляется:**
- Убедиться что пользователь зарегистрирован
- Проверить формат номера счета (7 пар цифр через дефис)
- Проверить что счет уникален (не добавлен другому пользователю)

439
docs/AUTO_CONFIRM_SYSTEM.md Normal file
View File

@@ -0,0 +1,439 @@
# Система автоматического подтверждения и повторного розыгрыша
## 🎯 Обзор системы
Реализована полная система автоматического подтверждения выигрышей с возможностью повторного розыгрыша для неподтвержденных призов.
---
## 🔄 Как это работает
### 1. Победитель получает уведомление
После проведения розыгрыша победителю автоматически отправляется сообщение с **интерактивной кнопкой**:
```
🎉 Поздравляем! Ваш счет выиграл!
🎯 Розыгрыш: Новогодний розыгрыш
🏆 Место: 1
🎁 Приз: Главный приз
💳 Счет: 11-22-33-44-55-66-77
У вас есть 24 часа для подтверждения!
Нажмите кнопку ниже, чтобы подтвердить получение приза.
Если вы не подтвердите в течение 24 часов, приз будет разыгран заново.
[✅ Подтвердить получение приза]
[📞 Связаться с администратором]
```
### 2. Победитель подтверждает выигрыш
Пользователь нажимает кнопку "✅ Подтвердить получение приза":
**Что происходит:**
- В БД устанавливается `is_claimed = True`
- Сохраняется время подтверждения `claimed_at`
- Победитель видит подтверждение:
```
✅ Выигрыш успешно подтвержден!
🎯 Розыгрыш: Новогодний розыгрыш
🏆 Место: 1
🎁 Приз: Главный приз
🎊 Поздравляем! Администратор свяжется с вами
для передачи приза в ближайшее время.
Спасибо за участие!
```
**Администраторы получают уведомление:**
```
✅ Победитель подтвердил получение приза!
🎯 Розыгрыш: Новогодний розыгрыш
🏆 Место: 1
🎁 Приз: Главный приз
👤 Победитель: Иван (@ivan)
🎫 Клубная карта: 2223
📱 Телефон: +7 900 123-45-67
💳 Счет: 11-22-33-44-55-66-77
```
### 3. Если победитель не подтверждает (24 часа)
Администратор проверяет неподтвержденные выигрыши:
```
/check_unclaimed 1
```
**Ответ бота:**
```
⚠️ Неподтвержденные выигрыши в розыгрыше 'Новогодний розыгрыш':
🏆 1 место - Главный приз
👤 Иван (КК: 2223)
💳 11-22-33-44-55-66-77
⏰ Прошло: 26 часов
🏆 3 место - Третий приз
👤 Петр (КК: 3334)
💳 22-33-44-55-66-77-88
⏰ Прошло: 30 часов
📊 Всего неподтвержденных: 2
Используйте /redraw 1 для повторного розыгрыша
```
### 4. Повторный розыгрыш
Администратор запускает переигровку:
```
/redraw 1
```
**Что происходит:**
1. **Система находит неподтвержденные выигрыши** (старше 24 часов)
2. **Получает пул участников** (исключая текущих победителей)
3. **Случайно выбирает новых победителей** для каждого неподтвержденного места
4. **Удаляет старых победителей** из БД
5. **Создает новых победителей**
6. **Отправляет уведомления** новым победителям (с кнопкой подтверждения)
**Результат для администратора:**
```
🔄 Повторный розыгрыш завершен!
🎯 Розыгрыш: Новогодний розыгрыш
📊 Переиграно мест: 2
🏆 1 место - Главный приз
❌ Было: 11-22-33-44-55-66-77
✅ Стало: 99-88-77-66-55-44-33
🏆 3 место - Третий приз
❌ Было: 22-33-44-55-66-77-88
✅ Стало: 12-34-56-78-90-12-34
📨 Новым победителям отправлены уведомления
```
**Новые победители получают** то же уведомление с кнопкой подтверждения и 24-часовым лимитом.
---
## 📋 Админские команды
### `/check_unclaimed <lottery_id>`
Проверить неподтвержденные выигрыши старше 24 часов.
**Пример:**
```
/check_unclaimed 1
```
**Показывает:**
- Список всех неподтвержденных выигрышей
- Информацию о победителях
- Сколько времени прошло с момента уведомления
### `/redraw <lottery_id>`
Переиграть розыгрыш для неподтвержденных выигрышей.
**Пример:**
```
/redraw 1
```
**Требования:**
- Должны быть неподтвержденные выигрыши старше 24 часов
- Должны быть доступные участники (не победители)
---
## 🗄️ Изменения в базе данных
### Таблица `winners`
Добавлено новое поле:
- `claimed_at` (TIMESTAMP) - время подтверждения выигрыша победителем
### Миграция
Файл: `migrations/versions/004_add_claimed_at.py`
Применить:
```bash
alembic upgrade head
```
---
## 🔧 Технические детали
### Обработчик подтверждения
**Файл:** `main.py`
**Callback:** `confirm_win_{winner_id}`
**Функция:** `confirm_winner_response()`
**Логика:**
1. Проверяет существование выигрыша
2. Проверяет, что подтверждает владелец
3. Устанавливает `is_claimed = True` и `claimed_at = now()`
4. Обновляет сообщение победителя
5. Уведомляет всех администраторов
### Система уведомлений
**Функция:** `notify_winners_async()`
**Изменения:**
- Добавлена кнопка "✅ Подтвердить получение приза"
- Добавлено предупреждение о 24-часовом лимите
- Добавлена кнопка "📞 Связаться с администратором"
### Повторный розыгрыш
**Файл:** `src/handlers/redraw_handlers.py`
**Команды:**
- `check_unclaimed_winners()` - проверка
- `redraw_lottery()` - переигровка
**Алгоритм:**
1. Получает всех победителей розыгрыша
2. Фильтрует неподтвержденных (is_claimed=False, is_notified=True, >24ч)
3. Получает пул участников (исключая победителей)
4. Для каждого неподтвержденного:
- Выбирает случайного участника
- Удаляет старого победителя
- Создает нового победителя
- Отправляет уведомление
---
## 📊 Состояния выигрыша
### Таймлайн жизни выигрыша:
```
1. Розыгрыш проведен
├─ is_notified: False
├─ is_claimed: False
└─ claimed_at: NULL
2. Уведомление отправлено
├─ is_notified: True
├─ is_claimed: False
└─ claimed_at: NULL
3А. Победитель подтвердил (успех)
├─ is_notified: True
├─ is_claimed: True
└─ claimed_at: 2025-11-16 13:00:00
3Б. Прошло 24 часа (неудача)
├─ is_notified: True
├─ is_claimed: False
├─ claimed_at: NULL
└─ ⏰ time_passed > 24h → переигровка
4. После переигровки (новый победитель)
├─ Старый победитель удален
├─ Создан новый победитель
├─ is_notified: True (после отправки)
├─ is_claimed: False
└─ claimed_at: NULL → снова 24 часа
```
---
## ⚠️ Важные моменты
### Безопасность
1. **Проверка владельца**: Только владелец счета может подтвердить выигрыш
2. **Повторное подтверждение**: Если выигрыш уже подтвержден, показывается соответствующее сообщение
3. **Права администратора**: Только админы могут запускать `/redraw`
### Ограничения
1. **24-часовой лимит**: Жестко закодирован, но легко изменить в коде
2. **Пул участников**: Если все участники уже победители, переигровка невозможна
3. **Уникальность**: Один счет не может выиграть дважды в одном розыгрыше
### Отказоустойчивость
1. **Уведомления**: Если не удается отправить - продолжает работу
2. **Транзакции**: Все изменения в БД атомарны
3. **Логирование**: Все действия записываются в лог
---
## 🎯 Типичные сценарии
### Сценарий 1: Все подтвердили
```
1. Розыгрыш → 3 победителя
2. Все 3 нажали кнопку подтверждения
3. Админ: /check_unclaimed 1
→ "✅ Все победители подтвердили выигрыш"
4. Приз передается всем победителям
```
### Сценарий 2: Один не подтвердил
```
1. Розыгрыш → 3 победителя
2. 2 подтвердили, 1 игнорирует
3. Через 25 часов админ: /check_unclaimed 1
→ "⚠️ 1 неподтвержденный выигрыш"
4. Админ: /redraw 1
5. Система переигрывает 1 место
6. Новый победитель получает уведомление
7. У нового победителя снова 24 часа
```
### Сценарий 3: Множественная переигровка
```
1. Розыгрыш → 5 победителей
2. 3 подтвердили, 2 игнорируют
3. Через 25 часов: /redraw 1
4. 2 новых победителя выбраны
5. Один из новых тоже игнорирует
6. Через 25 часов: /redraw 1 снова
7. Выбран еще один новый победитель
```
---
## 📈 Статистика и мониторинг
### Проверка статуса
```
/winner_status 1
```
Покажет:
- ✅ Подтвержденные выигрыши (с is_claimed=True)
- ⏳ Ожидающие подтверждения (с is_claimed=False)
- 📨 Статус уведомления (is_notified)
### SQL запросы для админа
**Найти все неподтвержденные старше 24 часов:**
```sql
SELECT w.*, l.title
FROM winners w
JOIN lotteries l ON w.lottery_id = l.id
WHERE w.is_notified = TRUE
AND w.is_claimed = FALSE
AND w.created_at < NOW() - INTERVAL '24 hours';
```
**Статистика по подтверждениям:**
```sql
SELECT
lottery_id,
COUNT(*) as total_winners,
SUM(CASE WHEN is_claimed THEN 1 ELSE 0 END) as confirmed,
SUM(CASE WHEN NOT is_claimed THEN 1 ELSE 0 END) as unconfirmed
FROM winners
WHERE is_notified = TRUE
GROUP BY lottery_id;
```
---
## 🚀 Использование
### Для администратора
1. **После розыгрыша**: Ничего не делать - система отправит уведомления
2. **Через 24-30 часов**: Проверить `/check_unclaimed <id>`
3. **Если есть неподтвержденные**: Запустить `/redraw <id>`
4. **Повторить** при необходимости
### Для победителя
1. **Получить уведомление** с кнопкой
2. **Нажать "✅ Подтвердить"**
3. **Дождаться связи с админом**
4. **Получить приз**
---
## 💡 Рекомендации
### Оптимальные настройки
- **Лимит подтверждения**: 24 часа (можно увеличить до 48ч)
- **Частота проверки**: 1-2 раза в день
- **Уведомления**: Включить push-уведомления в боте
### Коммуникация с пользователями
После розыгрыша отправьте общее сообщение:
```
🎉 Розыгрыш завершен!
Если вы выиграли, вам придет сообщение с кнопкой подтверждения.
У вас будет 24 часа чтобы подтвердить выигрыш!
Если не подтвердите - приз будет переигран.
```
---
## 🔮 Возможные улучшения
1. **Напоминания**: Отправка напоминания за 2 часа до истечения срока
2. **Гибкий лимит**: Разные лимиты для разных типов призов
3. **История**: Логирование всех переигровок
4. **Статистика**: Процент подтверждений, среднее время подтверждения
5. **Автоматизация**: Cron-задача для автоматической переигровки
---
## ✅ Преимущества системы
- 🤖 **Полная автоматизация**: Минимум ручной работы для админа
- ⏱️ **Справедливость**: Четкий дедлайн для всех
- 🔄 **Эффективность**: Призы не "зависают" у неактивных победителей
- 📊 **Прозрачность**: Полная история всех действий
- 🛡️ **Безопасность**: Только владелец может подтвердить
- 💬 **UX**: Простая кнопка вместо сложной верификации
---
## 📞 Поддержка
Если что-то пошло не так:
1. Проверьте логи бота
2. Проверьте состояние БД (claimed_at, is_notified, is_claimed)
3. Используйте `/winner_status <id>` для диагностики
4. При критических ошибках - используйте `/verify_winner` для ручного подтверждения

72
docs/CLEAR_DATABASE.md Normal file
View File

@@ -0,0 +1,72 @@
# Очистка базы данных - Краткое руководство
## 🎯 Быстрый старт
```bash
make clear-db
```
Система попросит подтверждение, введите `yes`.
## ✅ Результат
База данных будет полностью очищена:
- ✅ Удалены все пользователи
- ✅ Удалены все розыгрыши
- ✅ Удалены все счета
- ✅ Удалены все участия
- ✅ Удалены все победители
- ✅ Сброшены счетчики ID (начнутся с 1)
## 📊 Статистика очистки
Скрипт покажет сколько строк удалено из каждой таблицы:
```
✅ winner_verifications - удалено 0 строк
✅ winners - удалено 2 строк
✅ participations - удалено 1 строк
✅ accounts - удалено 2 строк
✅ lotteries - удалено 1 строк
✅ users - удалено 2 строк
```
## ⚠️ ВНИМАНИЕ
**Данные удаляются БЕЗ ВОЗМОЖНОСТИ ВОССТАНОВЛЕНИЯ!**
Перед запуском на production всегда делайте бэкап:
```bash
pg_dump -h 192.168.0.102 -U bot_user -d bot_db > backup.sql
```
## 🔧 Альтернативные способы
### Через Python напрямую
```bash
source .venv/bin/activate
python scripts/clear_database.py
```
### Через SQL (только данные, без сброса ID)
```sql
DELETE FROM winner_verifications;
DELETE FROM winners;
DELETE FROM participations;
DELETE FROM accounts;
DELETE FROM lotteries;
DELETE FROM users;
```
## 📖 Подробная документация
См. `scripts/README_CLEAR_DB.md` для полной документации.
## 🆘 Восстановление из бэкапа
```bash
psql -h 192.168.0.102 -U bot_user -d bot_db < backup.sql
```

152
docs/QUICK_GUIDE.md Normal file
View File

@@ -0,0 +1,152 @@
# Быстрая шпаргалка - Команды администратора
## 🚀 Основные команды
### Добавление счетов
**Один счет (быстро):**
```
/add_account 2223 11-22-33-44-55-66-77
```
**Несколько счетов (пакетом):**
```
/add_account
2223 11-22-33-44-55-66-77
2223 88-99-00-11-22-33-44
3334 12-34-56-78-90-12-34
```
### Управление счетами
```
/remove_account 11-22-33-44-55-66-77 # Деактивировать
/user_info 2223 # Информация о пользователе
```
### Работа с розыгрышами
```
/winner_status 1 # Статус победителей
/verify_winner AB12CD34 1 # Подтвердить выигрыш
```
---
## 📋 Типичные сценарии
### Новый клиент (полный цикл)
1. Клиент регистрируется: `/start` → "Зарегистрироваться"
2. Админ добавляет счета:
```
/add_account 2223 11-22-33-44-55-66-77
```
3. Выбрать розыгрыш из списка
4. Готово!
### Массовое добавление (10+ счетов)
```
/add_account
2223 11-22-33-44-55-66-77
2223 22-33-44-55-66-77-88
3334 33-44-55-66-77-88-99
3334 44-55-66-77-88-99-00
5556 55-66-77-88-99-00-11
```
→ Выбрать розыгрыш → Готово!
### Проведение розыгрыша
1. В боте: "Розыгрыши" → Выбрать → "Провести розыгрыш"
2. Победители получат уведомления автоматически
3. Проверить: `/winner_status 1`
### Подтверждение победителя
1. Победитель сообщает код: `AB12CD34`
2. Админ: `/verify_winner AB12CD34 1`
3. Система подтверждает и уведомляет победителя
---
## 💡 Полезные советы
- **Формат счета:** `XX-XX-XX-XX-XX-XX-XX` (7 пар цифр)
- **Клубная карта:** Любые цифры (например: 2223, 5556)
- **Код верификации:** Генерируется автоматически при регистрации
- **Отмена операции:** `/cancel` во время ввода данных
---
## ⚠️ Частые ошибки
| Ошибка | Причина | Решение |
|--------|---------|---------|
| "Пользователь не найден" | Клубная карта не зарегистрирована | Попросить зарегистрироваться через /start |
| "Счет уже существует" | Номер занят другим пользователем | Проверить номер или деактивировать старый |
| "Неверный формат" | Формат счета неправильный | Использовать XX-XX-XX-XX-XX-XX-XX |
| Нет розыгрышей | Нет активных розыгрышей | Создать розыгрыш через админ-панель |
---
## 🔍 Проверка данных
### Проверить пользователя:
```
/user_info 2223
```
Увидите:
- Все счета пользователя
- Код верификации
- Историю выигрышей
### Проверить победителей:
```
/winner_status 1
```
Увидите:
- Список всех победителей
- Статус подтверждения (✅/⏳)
- Информацию о владельцах
---
## 📞 Пользовательские команды
Клиенты используют:
- `/start` - Главное меню / регистрация
- `/my_code` - Показать код верификации
- `/my_accounts` - Список счетов
- "Мой счет" (через меню) - Подробная информация
---
## 🎯 Быстрый старт (за 1 минуту)
```bash
# 1. Клиент регистрируется
Клиент → /start → "Зарегистрироваться" → Вводит данные
# 2. Админ добавляет счета
/add_account
2223 11-22-33-44-55-66-77
2223 88-99-00-11-22-33-44
# 3. Выбрать розыгрыш
Нажать "🎯 Новогодний розыгрыш"
# 4. Провести розыгрыш
"Розыгрыши" → Выбрать → "Провести"
# 5. Подтвердить победителя
/verify_winner AB12CD34 1
✅ Готово!
```

261
docs/REGISTRATION_SYSTEM.md Normal file
View File

@@ -0,0 +1,261 @@
# Система регистрации и верификации выигрышей
## Архитектура
### Модели данных
#### 1. User (Расширенная)
- `club_card_number` - номер клубной карты (уникальный идентификатор клиента)
- `phone` - телефон для связи
- `is_registered` - прошел ли полную регистрацию
- `verification_code` - секретный код для подтверждения выигрыша (генерируется автоматически)
#### 2. Account (Новая)
- `account_number` - номер счета в формате XX-XX-XX-XX-XX-XX-XX
- `owner_id` - владелец счета (связь с User через club_card_number)
- `is_active` - активен ли счет
#### 3. WinnerVerification (Новая)
- `winner_id` - связь с Winner
- `verification_token` - токен для подтверждения выигрыша
- `is_verified` - подтвержден ли выигрыш
- `expires_at` - срок действия токена (24 часа)
## Процессы
### 1. Регистрация пользователя
**Инициатор:** Обычный пользователь через бота
**Шаги:**
1. Пользователь отправляет `/start`
2. Бот проверяет `is_registered`
3. Если `False` - запрашивает:
- Номер клубной карты
- Телефон (опционально)
4. Генерируется `verification_code` (8-символьный уникальный код)
5. Код показывается пользователю: "Ваш код верификации: **AB12CD34**. Сохраните его!"
6. `is_registered = True`
### 2. Создание счета администратором
**Инициатор:** Администратор
**Формат команды:**
```
/add_account 2223 11-22-33-44-55-66-77
```
**Процесс:**
1. Админ отправляет команду с номером КК и счетом
2. Бот находит пользователя по `club_card_number = 2223`
3. Создается запись в таблице `accounts`:
```python
Account(
account_number="11-22-33-44-55-66-77",
owner_id=user.id,
is_active=True
)
```
4. Пользователю отправляется уведомление:
```
К вашему профилю добавлен счет:
11-22-33-44-55-66-77
```
### 3. Проведение розыгрыша с уведомлением победителей
**Процесс:**
1. Администратор проводит розыгрыш
2. Выбирается случайный счет (например, `11-22-33-44-55-66-77`)
3. Система ищет владельца счета:
```python
account = Account.query.filter_by(account_number="11-22-33-44-55-66-77").first()
user = account.owner if account else None
```
4. Создается запись `Winner` и `WinnerVerification`:
```python
winner = Winner(
lottery_id=lottery.id,
user_id=user.id if user else None,
account_number="11-22-33-44-55-66-77",
place=1,
prize="iPhone 15"
)
verification = WinnerVerification(
winner_id=winner.id,
verification_token=generate_token(),
expires_at=now + 24hours
)
```
5. Если `user` найден - отправляется личное сообщение:
```
🎉 ПОЗДРАВЛЯЕМ!
Вы выиграли 1 место в розыгрыше "Новогодний розыгрыш"!
🏆 Приз: iPhone 15
📋 Ваш выигрышный счет: 11-22-33-44-55-66-77
✅ Для подтверждения выигрыша:
1. Свяжитесь с администратором @admin_username
2. Сообщите ваш код верификации: AB12CD34
3. Администратор подтвердит ваш выигрыш в системе
⏰ Срок подтверждения: 24 часа
```
### 4. Верификация выигрыша администратором
**Сценарий:**
1. Победитель связывается с админом в личных сообщениях
2. Сообщает код верификации: `AB12CD34`
3. Админ проверяет код в боте:
```
/verify_winner AB12CD34 1
```
где `1` - ID розыгрыша
4. Система:
- Находит пользователя по `verification_code = "AB12CD34"`
- Проверяет, что у него есть выигрыш в розыгрыше #1
- Помечает `winner.is_claimed = True`
- Обновляет `verification.is_verified = True`
5. Победителю отправляется:
```
✅ Ваш выигрыш подтвержден!
Администратор свяжется с вами для получения приза.
```
## Дополнительные механизмы безопасности
### 1. Двухфакторная верификация (опционально)
Можно добавить отправку одноразового кода на телефон победителя.
### 2. Ограничение по времени
Токены верификации действуют 24 часа. После истечения срока требуется повторная генерация.
### 3. Логирование
Все действия с выигрышами логируются:
- Создание выигрыша
- Отправка уведомления
- Подтверждение администратором
- Получение приза
## API для админа
### Команды бота
#### `/add_account <club_card> <account_number>`
Добавить счет к профилю клиента
```
/add_account 2223 11-22-33-44-55-66-77
```
#### `/remove_account <account_number>`
Деактивировать счет
```
/remove_account 11-22-33-44-55-66-77
```
#### `/verify_winner <verification_code> <lottery_id>`
Подтвердить выигрыш победителя
```
/verify_winner AB12CD34 1
```
#### `/winner_status <lottery_id>`
Показать статус всех победителей розыгрыша
```
/winner_status 1
Результаты:
1 место - @username (КК: 2223) ✅ Подтвержден
2 место - Счет: 33-44-55-66-77-88-99 ⏳ Ожидает подтверждения
3 место - @user2 (КК: 4445) ❌ Не подтвержден (истек срок)
```
## Аналитика и отчеты
### Запросы для аналитики
```sql
-- Победители по клубным картам
SELECT
u.club_card_number,
u.first_name,
COUNT(w.id) as total_wins,
SUM(CASE WHEN w.is_claimed THEN 1 ELSE 0 END) as claimed_wins
FROM users u
JOIN winners w ON w.user_id = u.id
GROUP BY u.id;
-- Счета с наибольшим количеством участий
SELECT
a.account_number,
u.club_card_number,
COUNT(p.id) as participation_count
FROM accounts a
JOIN users u ON u.id = a.owner_id
JOIN participations p ON p.account_id = a.id
GROUP BY a.id, u.id
ORDER BY participation_count DESC;
-- Невостребованные выигрыши
SELECT
l.title,
w.place,
w.prize,
w.account_number,
u.club_card_number
FROM winners w
JOIN lotteries l ON l.id = w.lottery_id
LEFT JOIN users u ON u.id = w.user_id
WHERE w.is_claimed = false
AND w.created_at < NOW() - INTERVAL '24 hours';
```
## Миграция существующих данных
Если у вас уже есть данные в старой схеме:
```python
# Миграция старых account_number из users в таблицу accounts
async def migrate_accounts():
users = await session.execute(select(User).where(User.account_number != None))
for user in users.scalars():
account = Account(
account_number=user.account_number,
owner_id=user.id,
is_active=True
)
session.add(account)
user.account_number = None # Очищаем старое поле
await session.commit()
```
## Тестирование
### Сценарий 1: Полный цикл выигрыша
1. Регистрация пользователя (КК: 2223)
2. Админ добавляет счет: `/add_account 2223 11-22-33-44-55-66-77`
3. Счет участвует в розыгрыше
4. Счет выигрывает 1 место
5. Пользователю приходит уведомление с кодом
6. Пользователь связывается с админом, сообщает код
7. Админ подтверждает: `/verify_winner AB12CD34 1`
8. Выигрыш получен ✅
### Сценарий 2: Выигрыш незарегистрированного счета
1. Админ добавляет счет без владельца
2. Счет участвует и выигрывает
3. Бот показывает в публичном объявлении: "Счет 11-22-33-44-55-66-77 выиграл!"
4. Владелец счета регистрируется в боте
5. Админ привязывает счет к владельцу
6. Дальше стандартная процедура верификации

424
docs/UPDATE_LOG.md Normal file
View File

@@ -0,0 +1,424 @@
# Обновления системы - 16.11.2025
## ✅ Исправленные ошибки
### 1. Ошибка "User object has no attribute 'account_number'"
**Проблема:** В старой системе поле `account_number` было в таблице `users`, но после миграции оно перенесено в отдельную таблицу `accounts`.
**Решение:**
- Обновлена функция `show_my_account()` в `main.py`
- Теперь использует `AccountService.get_user_accounts()` для получения всех счетов пользователя
- Показывает список всех счетов с их статусом (активен/неактивен)
**Новый функционал в "Мой счет":**
```
💳 Ваши счета
🎫 Клубная карта: 2223
🔑 Код верификации: AB12CD34
Счета (2):
1. 11-22-33-44-55-66-77
✅ Активен
2. 88-99-00-11-22-33-44
✅ Активен
```
---
## 🆕 Новый функционал
### 1. Улучшенная команда `/add_account`
#### Режим 1: Быстрое добавление одного счета
```
/add_account 2223 11-22-33-44-55-66-77
```
#### Режим 2: Интерактивное добавление (один или несколько)
```
/add_account
```
Затем отправьте данные:
**Один счет:**
```
2223 11-22-33-44-55-66-77
```
**Несколько счетов (пакетное добавление):**
```
2223 11-22-33-44-55-66-77
2223 88-99-00-11-22-33-44
3334 12-34-56-78-90-12-34
5556 99-88-77-66-55-44-33
```
**Формат:** `клубная_карта номер_счета` (через пробел, каждый счет с новой строки)
---
### 2. Автоматическое добавление счетов в розыгрыш
После успешного добавления счетов система **автоматически предлагает** добавить их в активный розыгрыш:
```
✅ Счет успешно добавлен!
🎫 Клубная карта: 2223
💳 Счет: 11-22-33-44-55-66-77
👤 Владелец: Иван
📨 Владельцу отправлено уведомление
Добавить счета в розыгрыш?
Выберите розыгрыш из списка:
[🎯 Новогодний розыгрыш]
[🎯 Летний розыгрыш]
[❌ Пропустить]
```
**Процесс:**
1. Администратор добавляет счета командой `/add_account`
2. Система показывает список активных розыгрышей
3. Администратор выбирает розыгрыш или пропускает
4. Счета автоматически добавляются в выбранный розыгрыш
**Преимущества:**
- ✅ Экономия времени - не нужно вручную добавлять каждый счет
- ✅ Меньше ошибок - система проверяет дубликаты
- ✅ Удобство - всё в одном процессе
- ✅ Гибкость - можно пропустить добавление
---
### 3. Пакетное добавление счетов
**Сценарий использования:**
У вас есть список новых клиентов с их счетами. Вместо добавления каждого по отдельности:
```
/add_account
2223 11-22-33-44-55-66-77
2223 88-99-00-11-22-33-44
3334 12-34-56-78-90-12-34
3334 99-88-77-66-55-44-33
5556 11-11-11-11-11-11-11
```
**Результат:**
```
📊 Результаты добавления счетов
✅ Успешно добавлено: 5
• 2223 → 11-22-33-44-55-66-77
👤 Иван
• 2223 → 88-99-00-11-22-33-44
👤 Иван
• 3334 → 12-34-56-78-90-12-34
👤 Петр
• 3334 → 99-88-77-66-55-44-33
👤 Петр
• 5556 → 11-11-11-11-11-11-11
👤 Мария
Добавить счета в розыгрыш?
```
**Обработка ошибок:**
Если какие-то счета невалидны, система покажет:
```
❌ Ошибки: 2
• Строка 3 (9999 123-456): Пользователь с клубной картой 9999 не найден
• Строка 5 (2223 11-22-33): Неверный формат номера счета
```
Остальные счета будут добавлены успешно.
---
## 🔄 Обновленный процесс работы
### Полный цикл: Регистрация → Счета → Розыгрыш
**Шаг 1: Клиент регистрируется**
```
Клиент: /start
Клиент: Нажимает "Зарегистрироваться"
Клиент: Вводит клубную карту "2223"
Клиент: Вводит телефон или пропускает
Система: Показывает код верификации "AB12CD34"
```
**Шаг 2: Администратор добавляет счета**
```
Админ: /add_account
2223 11-22-33-44-55-66-77
2223 88-99-00-11-22-33-44
Система: Добавляет оба счета
Система: Отправляет уведомления владельцу
Система: Предлагает добавить в розыгрыш
```
**Шаг 3: Администратор выбирает розыгрыш**
```
Админ: Нажимает "🎯 Новогодний розыгрыш"
Система: Добавляет оба счета в розыгрыш
Система: Показывает результат:
✅ Добавлено счетов: 2
```
**Шаг 4: Клиент проверяет свои счета**
```
Клиент: /start → "Мой счет"
Видит:
💳 Ваши счета
🎫 Клубная карта: 2223
🔑 Код верификации: AB12CD34
Счета (2):
1. 11-22-33-44-55-66-77 ✅ Активен
2. 88-99-00-11-22-33-44 ✅ Активен
```
**Шаг 5: Проведение розыгрыша**
```
Админ: Нажимает "Провести розыгрыш"
Система: Выбирает победителей
Система: Находит владельцев счетов-победителей
Система: Отправляет уведомления с кодами верификации
```
**Шаг 6: Подтверждение выигрыша**
```
Победитель: Связывается с админом, называет код "AB12CD34"
Админ: /verify_winner AB12CD34 1
Система: Подтверждает выигрыш
Система: Отправляет уведомление победителю
```
---
## 📝 Примеры использования
### Пример 1: Добавление счетов для одного пользователя
```
/add_account
2223 11-22-33-44-55-66-77
2223 22-33-44-55-66-77-88
2223 33-44-55-66-77-88-99
```
Результат: Все 3 счета добавлены пользователю с клубной картой 2223
### Пример 2: Добавление счетов для разных пользователей
```
/add_account
2223 11-22-33-44-55-66-77
3334 22-33-44-55-66-77-88
5556 33-44-55-66-77-88-99
7778 44-55-66-77-88-99-00
```
Результат: Каждый счет добавлен соответствующему пользователю
### Пример 3: Быстрое добавление с немедленным участием
```
/add_account 2223 11-22-33-44-55-66-77
[Выбрать розыгрыш]
[Нажать "🎯 Новогодний розыгрыш"]
```
Результат: Счет добавлен и сразу участвует в розыгрыше
---
## 🛠️ Технические детали
### Новая FSM структура в admin_account_handlers.py
```python
class AddAccountStates(StatesGroup):
waiting_for_data = State() # Ожидание данных счетов
choosing_lottery = State() # Выбор розыгрыша
```
### Новые callback handlers
- `add_to_lottery_{lottery_id}` - Добавить счета в розыгрыш
- `skip_lottery_add` - Пропустить добавление в розыгрыш
### Обновленные функции
1. **add_account_command()** - Точка входа, поддерживает оба режима
2. **process_single_account()** - Обработка одного счета из команды
3. **process_accounts_data()** - Обработка пакета счетов
4. **show_lottery_selection()** - Показать выбор розыгрыша
5. **add_accounts_to_lottery()** - Добавить счета в выбранный розыгрыш
### Проверки безопасности
- ✅ Проверка существования пользователя по клубной карте
- ✅ Проверка уникальности номера счета
- ✅ Проверка формата номера (XX-XX-XX-XX-XX-XX-XX)
- ✅ Проверка дубликатов при добавлении в розыгрыш
- ✅ Проверка прав администратора
---
## 📊 Сравнение: До и После
### Старый процесс (до обновления):
```
1. /add_account 2223 11-22-33-44-55-66-77
2. [Ждем подтверждения]
3. Вручную находим розыгрыш
4. Вручную добавляем счет в розыгрыш
5. Повторяем для каждого счета
```
**Время на 10 счетов:** ~5-7 минут
### Новый процесс (после обновления):
```
1. /add_account
2. Вставляем 10 строк с данными
3. Выбираем розыгрыш из списка
4. Готово!
```
**Время на 10 счетов:** ~30 секунд
**Экономия времени: 90%!**
---
## 🔍 Отладка и решение проблем
### Проблема: Не показываются розыгрыши после добавления счетов
**Причина:** Нет активных розыгрышей
**Решение:** Создайте активный розыгрыш через админ-панель
### Проблема: Ошибка "Пользователь не найден"
**Причина:** Клубная карта не зарегистрирована
**Решение:**
1. Попросите пользователя зарегистрироваться через /start
2. Или проверьте правильность клубной карты через /user_info
### Проблема: "Счет уже существует"
**Причина:** Этот номер счета уже привязан к другому пользователю
**Решение:**
1. Проверьте номер счета
2. Если ошибка - деактивируйте старый: /remove_account <номер>
3. Добавьте заново
### Проблема: Счет не добавился в розыгрыш
**Причина:** Счет уже участвует в этом розыгрыше
**Решение:** Это нормально, система предотвращает дубликаты
---
## 📚 Команды для администратора (обновленный список)
### Управление счетами:
- `/add_account` - Добавить счета (интерактивно или пакетом)
- `/add_account <club_card> <account>` - Быстрое добавление одного счета
- `/remove_account <account>` - Деактивировать счет
- `/user_info <club_card>` - Информация о пользователе
### Управление розыгрышами:
- Создание через интерфейс (кнопка "Создать розыгрыш")
- Проведение через интерфейс (кнопка "Провести розыгрыш")
- `/winner_status <lottery_id>` - Статус победителей
### Верификация:
- `/verify_winner <code> <lottery_id>` - Подтвердить выигрыш
---
## 🎯 Рекомендации по использованию
### Для массового добавления счетов:
1. **Подготовьте данные в текстовом файле**
```
2223 11-22-33-44-55-66-77
2223 88-99-00-11-22-33-44
3334 12-34-56-78-90-12-34
...
```
2. **Скопируйте и вставьте в бота**
```
/add_account
[Вставить все строки]
```
3. **Выберите розыгрыш**
4. **Проверьте результаты**
### Для одиночных счетов:
Используйте быстрый режим:
```
/add_account 2223 11-22-33-44-55-66-77
```
### Для новых пользователей:
1. Сначала попросите зарегистрироваться
2. Затем добавьте счета
3. Счета автоматически будут доступны в розыгрышах
---
## ✅ Чек-лист для администратора
При добавлении новых пользователей:
- [ ] Пользователь прошел регистрацию (/start → Зарегистрироваться)
- [ ] Пользователь получил код верификации
- [ ] Добавлены все счета через /add_account
- [ ] Счета добавлены в активный розыгрыш
- [ ] Пользователю отправлены уведомления
- [ ] Проверено через /user_info <club_card>
При проведении розыгрыша:
- [ ] Все счета добавлены и активны
- [ ] Розыгрыш настроен (призы, описание)
- [ ] Нажата кнопка "Провести розыгрыш"
- [ ] Победители получили уведомления
- [ ] Проверен статус через /winner_status <id>
При подтверждении выигрышей:
- [ ] Победитель сообщил код верификации
- [ ] Код проверен командой /verify_winner
- [ ] Победитель получил подтверждение
- [ ] Приз передан победителю

370
main.py
View File

@@ -16,8 +16,12 @@ import sys
from src.core.config import BOT_TOKEN, ADMIN_IDS
from src.core.database import async_session_maker, init_db
from src.core.services import UserService, LotteryService, ParticipationService
from src.core.models import User
from src.handlers.admin_panel import admin_router
from src.handlers.account_handlers import account_router
from src.handlers.registration_handlers import router as registration_router
from src.handlers.admin_account_handlers import router as admin_account_router
from src.handlers.redraw_handlers import router as redraw_router
from src.utils.async_decorators import (
async_user_action, admin_async_action, db_operation,
TaskManagerMiddleware, shutdown_task_manager,
@@ -65,16 +69,19 @@ def is_admin(user_id: int) -> bool:
def get_main_keyboard(is_admin_user: bool = False) -> InlineKeyboardMarkup:
"""Главная клавиатура"""
buttons = [
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="list_lotteries")],
[InlineKeyboardButton(text="📝 Мои участия", callback_data="my_participations")],
[InlineKeyboardButton(text="💳 Мой счёт", callback_data="my_account")]
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="list_lotteries")]
]
if not is_admin_user:
buttons.extend([
[InlineKeyboardButton(text="📝 Мои участия", callback_data="my_participations")],
[InlineKeyboardButton(text="💳 Мой счёт", callback_data="my_account")]
])
if is_admin_user:
buttons.extend([
[InlineKeyboardButton(text="🔧 Админ-панель", callback_data="admin_panel")],
[InlineKeyboardButton(text=" Создать розыгрыш", callback_data="create_lottery")],
[InlineKeyboardButton(text="👑 Установить победителя", callback_data="set_winner")],
[InlineKeyboardButton(text="📊 Статистика задач", callback_data="task_stats")]
])
@@ -96,11 +103,29 @@ async def cmd_start(message: Message):
# Устанавливаем права администратора, если пользователь в списке
if message.from_user.id in ADMIN_IDS:
await UserService.set_admin(session, message.from_user.id, True)
is_registered = user.is_registered
is_admin_user = is_admin(message.from_user.id)
welcome_text = f"Добро пожаловать, {message.from_user.first_name}! 🎉\n\n"
welcome_text += "Это бот для проведения розыгрышей.\n\n"
# Для обычных пользователей - проверяем регистрацию
if not is_admin_user and not is_registered:
welcome_text += "⚠️ Для участия в розыгрышах необходимо пройти регистрацию.\n\n"
buttons = [
[InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")],
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="list_lotteries")]
]
await message.answer(
welcome_text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
)
return
welcome_text += "Выберите действие из меню ниже:"
if is_admin_user:
@@ -196,9 +221,18 @@ async def show_lottery_details(callback: CallbackQuery):
if winners:
text += "\n\n🏆 Победители:\n"
for winner in winners:
# Используем новую систему отображения
winner_display = format_winner_display(winner.user, lottery, show_sensitive_data=False)
text += f"{winner.place}. {winner_display}\n"
# Безопасное отображение победителя
if winner.user:
if winner.user.username:
winner_display = f"@{winner.user.username}"
else:
winner_display = f"{winner.user.first_name}"
elif winner.account_number:
winner_display = f"Счет: {winner.account_number}"
else:
winner_display = "Участник"
text += f"{winner.place}. {winner_display} - {winner.prize}\n"
else:
text += f"\n🟢 Статус: {'Активен' if lottery.is_active else 'Неактивен'}"
if is_participating:
@@ -241,7 +275,8 @@ async def join_lottery(callback: CallbackQuery):
await callback.answer("Ошибка получения данных пользователя", show_alert=True)
return
success = await LotteryService.add_participant(session, lottery_id, user.id)
# Используем правильный метод ParticipationService
success = await ParticipationService.add_participant(session, lottery_id, user.id)
if success:
await callback.answer("✅ Вы успешно присоединились к розыгрышу!", show_alert=True)
@@ -252,6 +287,237 @@ async def join_lottery(callback: CallbackQuery):
await show_lottery_details(callback)
async def notify_winners_async(bot: Bot, lottery_id: int, results: dict):
"""
Асинхронно отправить уведомления победителям с кнопкой подтверждения
Вызывается после проведения розыгрыша
"""
async with async_session_maker() as session:
from src.core.registration_services import AccountService, WinnerNotificationService
from src.core.models import Winner
from sqlalchemy import select
# Получаем информацию о розыгрыше
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
return
# Получаем всех победителей из БД
winners_result = await session.execute(
select(Winner).where(Winner.lottery_id == lottery_id)
)
winners = winners_result.scalars().all()
for winner in winners:
try:
# Если у победителя есть account_number, ищем владельца
if winner.account_number:
owner = await AccountService.get_account_owner(session, winner.account_number)
if owner and owner.telegram_id:
# Создаем токен верификации
verification = await WinnerNotificationService.create_verification_token(
session,
winner.id
)
# Формируем сообщение с кнопкой подтверждения
message = (
f"🎉 **Поздравляем! Ваш счет выиграл!**\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
f"💳 **Выигрышный счет: {winner.account_number}**\n\n"
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
f"Нажмите кнопку ниже, чтобы подтвердить получение приза по этому счету.\n"
f"Если вы не подтвердите в течение 24 часов, "
f"приз будет разыгран заново.\n\n"
f" Если у вас несколько выигрышных счетов, "
f"подтвердите каждый из них отдельно."
)
# Создаем кнопку подтверждения с указанием счета
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"✅ Подтвердить счет {winner.account_number}",
callback_data=f"confirm_win_{winner.id}"
)],
[InlineKeyboardButton(
text="📞 Связаться с администратором",
url=f"tg://user?id={ADMIN_IDS[0]}"
)]
])
# Отправляем уведомление с кнопкой
await bot.send_message(
owner.telegram_id,
message,
reply_markup=keyboard,
parse_mode="Markdown"
)
# Отмечаем, что уведомление отправлено
winner.is_notified = True
await session.commit()
logger.info(f"Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
# Если победитель - обычный пользователь (старая система)
elif winner.user_id:
user_result = await session.execute(
select(User).where(User.id == winner.user_id)
)
user = user_result.scalar_one_or_none()
if user and user.telegram_id:
message = (
f"🎉 Поздравляем! Вы выиграли!\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n\n"
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
f"Нажмите кнопку ниже, чтобы подтвердить получение приза."
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить получение приза",
callback_data=f"confirm_win_{winner.id}"
)]
])
await bot.send_message(
user.telegram_id,
message,
reply_markup=keyboard,
parse_mode="Markdown"
)
winner.is_notified = True
await session.commit()
logger.info(f"Отправлено уведомление победителю {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке уведомления победителю: {e}")
@router.callback_query(F.data.startswith("confirm_win_"))
async def confirm_winner_response(callback: CallbackQuery):
"""Обработка подтверждения выигрыша победителем"""
winner_id = int(callback.data.split("_")[2])
async with async_session_maker() as session:
from src.core.models import Winner
from sqlalchemy import select
from sqlalchemy.orm import joinedload
# Получаем выигрыш с загрузкой связанного розыгрыша
winner_result = await session.execute(
select(Winner)
.options(joinedload(Winner.lottery))
.where(Winner.id == winner_id)
)
winner = winner_result.scalar_one_or_none()
if not winner:
await callback.answer("❌ Выигрыш не найден", show_alert=True)
return
# Проверяем, не подтвержден ли уже этот конкретный счет
if winner.is_claimed:
await callback.message.edit_text(
"✅ **Выигрыш этого счета уже подтвержден!**\n\n"
f"🎯 Розыгрыш: {winner.lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
f"💳 Счет: {winner.account_number}\n\n"
"Администратор свяжется с вами для передачи приза.",
parse_mode="Markdown"
)
return
# Проверяем, что подтверждает владелец именно ЭТОГО счета
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if winner.account_number:
# Проверяем что счет принадлежит текущему пользователю
from src.core.registration_services import AccountService
owner = await AccountService.get_account_owner(session, winner.account_number)
if not owner or owner.telegram_id != callback.from_user.id:
await callback.answer(
f"❌ Счет {winner.account_number} вам не принадлежит",
show_alert=True
)
return
elif winner.user_id:
# Старая логика для выигрышей без счета
if not user or user.id != winner.user_id:
await callback.answer("❌ Это не ваш выигрыш", show_alert=True)
return
# Подтверждаем выигрыш ЭТОГО конкретного счета
from datetime import datetime, timezone
winner.is_claimed = True
winner.claimed_at = datetime.now(timezone.utc)
await session.commit()
# Обновляем сообщение с указанием счета
confirmation_text = (
"✅ **Выигрыш успешно подтвержден!**\n\n"
f"🎯 Розыгрыш: {winner.lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
)
if winner.account_number:
confirmation_text += f"💳 Счет: {winner.account_number}\n"
confirmation_text += (
"\n🎊 Поздравляем! Администратор свяжется с вами "
"для передачи приза в ближайшее время.\n\n"
"Спасибо за участие!"
)
await callback.message.edit_text(
confirmation_text,
parse_mode="Markdown"
)
# Уведомляем администраторов о подтверждении конкретного счета
for admin_id in ADMIN_IDS:
try:
admin_msg = (
f"✅ **Победитель подтвердил получение приза!**\n\n"
f"🎯 Розыгрыш: {winner.lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
)
# Обязательно показываем счет
if winner.account_number:
admin_msg += f"<EFBFBD> **Подтвержденный счет: {winner.account_number}**\n\n"
if user:
admin_msg += f"👤 Владелец: {user.first_name}"
if user.username:
admin_msg += f" (@{user.username})"
admin_msg += f"\n🎫 Клубная карта: {user.club_card_number}\n"
if user.phone:
admin_msg += f"📱 Телефон: {user.phone}\n"
await callback.bot.send_message(admin_id, admin_msg, parse_mode="Markdown")
except:
pass
logger.info(
f"Победитель {callback.from_user.id} подтвердил выигрыш {winner_id} "
f"(счет: {winner.account_number})"
)
await callback.answer("✅ Выигрыш подтвержден!", show_alert=True)
@router.callback_query(F.data.startswith("conduct_"))
async def conduct_lottery(callback: CallbackQuery):
"""Провести розыгрыш"""
@@ -262,6 +528,11 @@ async def conduct_lottery(callback: CallbackQuery):
lottery_id = int(callback.data.split("_")[1])
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
return
results = await LotteryService.conduct_draw(session, lottery_id)
if not results:
@@ -271,14 +542,26 @@ async def conduct_lottery(callback: CallbackQuery):
text = "🎉 Розыгрыш завершен!\n\n🏆 Победители:\n\n"
for place, winner_info in results.items():
user = winner_info['user']
user_obj = winner_info['user']
prize = winner_info['prize']
# Используем новую систему отображения
winner_display = format_winner_display(user, lottery, show_sensitive_data=False)
# Безопасное отображение победителя
if hasattr(user_obj, 'username') and user_obj.username:
winner_display = f"@{user_obj.username}"
elif hasattr(user_obj, 'first_name'):
winner_display = f"{user_obj.first_name}"
elif hasattr(user_obj, 'account_number'):
winner_display = f"Счет: {user_obj.account_number}"
else:
winner_display = "Участник"
text += f"{place}. {winner_display}\n"
text += f" 🎁 {prize}\n\n"
# Отправляем уведомления победителям асинхронно
asyncio.create_task(notify_winners_async(callback.bot, lottery_id, results))
text += "📨 Уведомления отправляются победителям...\n"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
@@ -339,6 +622,11 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer("❌ Ошибка получения данных пользователя")
await state.clear()
return
data = await state.get_data()
lottery = await LotteryService.create_lottery(
session,
@@ -529,7 +817,7 @@ async def show_my_participations(callback: CallbackQuery):
@router.callback_query(F.data == "my_account")
@db_operation()
async def show_my_account(callback: CallbackQuery):
"""Показать информацию о счёте пользователя"""
"""Показать информацию о счетах пользователя"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
@@ -537,27 +825,44 @@ async def show_my_account(callback: CallbackQuery):
await callback.answer("Пользователь не найден", show_alert=True)
return
text = "💳 **Ваш клиентский счёт**\n\n"
# Проверяем регистрацию
if not user.is_registered:
text = "❌ **Вы не зарегистрированы**\n\n"
text += "Пройдите регистрацию для доступа к счетам"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")],
[InlineKeyboardButton(text="🔙 Главное меню", callback_data="back_to_main")]
]),
parse_mode="Markdown"
)
return
if user.account_number:
# Показываем маскированный номер для безопасности
from src.utils.account_utils import mask_account_number
masked = mask_account_number(user.account_number, show_last_digits=6)
text += f"📋 Номер счёта: `{masked}`\n"
text += f"✅ Статус: Активен\n\n"
text += " Счёт используется для идентификации в розыгрышах"
# Получаем счета пользователя
from src.core.registration_services import AccountService
accounts = await AccountService.get_user_accounts(session, user.id)
text = "💳 **Ваши счета**\n\n"
if accounts:
text += f"🎫 Клубная карта: `{user.club_card_number}`\n"
text += f"<EFBFBD> Код верификации: `{user.verification_code}`\n\n"
text += f"**Счета ({len(accounts)}):**\n\n"
for i, acc in enumerate(accounts, 1):
status = "✅ Активен" if acc.is_active else "❌ Неактивен"
text += f"{i}. `{acc.account_number}`\n"
text += f" {status}\n\n"
text += " Счета используются для участия в розыгрышах"
else:
text += "❌ Счёт не привязан\n\n"
text += "Привяжите счёт для участия в розыгрышах"
text += f"🎫 Клубная карта: `{user.club_card_number}`\n\n"
text += "У вас нет счетов\n\n"
text += "Обратитесь к администратору для добавления счетов"
buttons = []
if user.account_number:
buttons.append([InlineKeyboardButton(text="🔄 Изменить счёт", callback_data="change_account")])
else:
buttons.append([InlineKeyboardButton(text=" Привязать счёт", callback_data="add_account")])
buttons.append([InlineKeyboardButton(text="🔙 Главное меню", callback_data="back_to_main")])
buttons = [[InlineKeyboardButton(text="🔙 Главное меню", callback_data="back_to_main")]]
await callback.message.edit_text(
text,
@@ -700,7 +1005,10 @@ async def main():
await set_commands()
# Подключение роутеров
dp.include_router(account_router) # Роутер для работы со счетами (приоритетный)
dp.include_router(registration_router) # Роутер регистрации (первый)
dp.include_router(admin_account_router) # Роутер админских команд для счетов
dp.include_router(redraw_router) # Роутер повторного розыгрыша
dp.include_router(account_router) # Роутер для работы со счетами
dp.include_router(router)
dp.include_router(admin_router)

View File

@@ -0,0 +1,108 @@
"""Add registration and account management
Revision ID: 003
Revises: init
Create Date: 2025-11-16 13:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '003'
down_revision = 'init'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Добавляем новые поля в users
op.add_column('users', sa.Column('phone', sa.String(20), nullable=True))
op.add_column('users', sa.Column('club_card_number', sa.String(50), nullable=True))
op.add_column('users', sa.Column('is_registered', sa.Boolean(), server_default='false', nullable=False))
op.add_column('users', sa.Column('verification_code', sa.String(10), nullable=True))
# Создаем индексы для users
op.create_index('ix_users_club_card_number', 'users', ['club_card_number'], unique=True)
op.create_index('ix_users_verification_code', 'users', ['verification_code'], unique=True)
# Удаляем старое поле account_number из users (оно теперь в отдельной таблице)
# Сначала удаляем индекс
op.drop_index('ix_users_account_number', table_name='users')
op.drop_column('users', 'account_number')
# Создаем таблицу accounts
op.create_table(
'accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('account_number', sa.String(20), nullable=False),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_accounts_account_number', 'accounts', ['account_number'], unique=True)
op.create_index('ix_accounts_owner_id', 'accounts', ['owner_id'])
# Добавляем поле account_id в participations
op.add_column('participations', sa.Column('account_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_participations_account_id',
'participations', 'accounts',
['account_id'], ['id'],
ondelete='SET NULL'
)
# Добавляем поля в winners для отслеживания статуса
op.add_column('winners', sa.Column('is_notified', sa.Boolean(), server_default='false', nullable=False))
op.add_column('winners', sa.Column('is_claimed', sa.Boolean(), server_default='false', nullable=False))
# Создаем таблицу winner_verifications
op.create_table(
'winner_verifications',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('winner_id', sa.Integer(), nullable=False),
sa.Column('verification_token', sa.String(32), nullable=False),
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['winner_id'], ['winners.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_winner_verifications_winner_id', 'winner_verifications', ['winner_id'], unique=True)
op.create_index('ix_winner_verifications_token', 'winner_verifications', ['verification_token'], unique=True)
def downgrade() -> None:
# Удаляем winner_verifications
op.drop_index('ix_winner_verifications_token', table_name='winner_verifications')
op.drop_index('ix_winner_verifications_winner_id', table_name='winner_verifications')
op.drop_table('winner_verifications')
# Удаляем новые поля из winners
op.drop_column('winners', 'is_claimed')
op.drop_column('winners', 'is_notified')
# Удаляем account_id из participations
op.drop_constraint('fk_participations_account_id', 'participations', type_='foreignkey')
op.drop_column('participations', 'account_id')
# Удаляем таблицу accounts
op.drop_index('ix_accounts_owner_id', table_name='accounts')
op.drop_index('ix_accounts_account_number', table_name='accounts')
op.drop_table('accounts')
# Возвращаем account_number в users
op.add_column('users', sa.Column('account_number', sa.String(20), nullable=True))
op.create_index('ix_users_account_number', 'users', ['account_number'], unique=True)
# Удаляем новые поля из users
op.drop_index('ix_users_verification_code', table_name='users')
op.drop_index('ix_users_club_card_number', table_name='users')
op.drop_column('users', 'verification_code')
op.drop_column('users', 'is_registered')
op.drop_column('users', 'club_card_number')
op.drop_column('users', 'phone')

View File

@@ -0,0 +1,26 @@
"""Add claimed_at field to winners
Revision ID: 004
Revises: 003
Create Date: 2025-11-16
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '004'
down_revision = '003'
branch_labels = None
depends_on = None
def upgrade():
"""Add claimed_at timestamp to winners table"""
op.add_column('winners', sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True))
def downgrade():
"""Remove claimed_at field"""
op.drop_column('winners', 'claimed_at')

227
scripts/README_CLEAR_DB.md Normal file
View File

@@ -0,0 +1,227 @@
# 🗑️ Скрипт очистки базы данных
## Описание
`clear_database.py` - скрипт для полной очистки всех данных из базы данных бота.
## ⚠️ ВНИМАНИЕ!
**Этот скрипт удаляет ВСЕ данные без возможности восстановления:**
- Всех пользователей
- Все розыгрыши (активные и завершенные)
- Все счета участников
- Все записи об участии
- Всех победителей
- Все записи верификации
## Использование
### Через Makefile (рекомендуется)
```bash
make clear-db
```
Система попросит подтверждение:
```
⚠️ ВНИМАНИЕ! Это удалит ВСЕ данные из базы данных!
- Все пользователи
- Все розыгрыши
- Все счета
- Все участия
- Всех победителей
Вы уверены? Введите 'yes' для подтверждения:
```
### Напрямую через Python
```bash
cd /home/trevor/new_lottery_bot
source .venv/bin/activate
python scripts/clear_database.py
```
## Что делает скрипт
1. **Подключается к базе данных** используя `DATABASE_URL` из `.env`
2. **Удаляет данные в правильном порядке:**
- `winner_verifications` - записи верификации победителей
- `winners` - записи о победителях
- `participations` - записи об участии в розыгрышах
- `accounts` - счета участников
- `lotteries` - розыгрыши
- `users` - пользователи
3. **Сбрасывает счетчики ID (sequences):**
- `users_id_seq` → 1
- `lotteries_id_seq` → 1
- `accounts_id_seq` → 1
- `participations_id_seq` → 1
- `winners_id_seq` → 1
- `winner_verifications_id_seq` → 1
4. **Фиксирует транзакцию** (COMMIT)
5. **Закрывает соединение**
## Вывод скрипта
Пример успешного выполнения:
```
INFO:__main__:🔌 Подключение к базе данных...
INFO:__main__:🗑️ Удаление данных из таблиц...
INFO:__main__:------------------------------------------------------------
INFO:__main__:✅ winner_verifications - удалено 0 строк
INFO:__main__:✅ winners - удалено 2 строк
INFO:__main__:✅ participations - удалено 1 строк
INFO:__main__:✅ accounts - удалено 2 строк
INFO:__main__:✅ lotteries - удалено 1 строк
INFO:__main__:✅ users - удалено 2 строк
INFO:__main__:------------------------------------------------------------
INFO:__main__:🔄 Сброс последовательностей...
INFO:__main__:------------------------------------------------------------
INFO:__main__:✅ users_id_seq
INFO:__main__:✅ lotteries_id_seq
INFO:__main__:✅ accounts_id_seq
INFO:__main__:✅ participations_id_seq
INFO:__main__:✅ winners_id_seq
INFO:__main__:✅ winner_verifications_id_seq
INFO:__main__:------------------------------------------------------------
INFO:__main__:============================================================
INFO:__main__:🎉 ВСЕ ДАННЫЕ УСПЕШНО УДАЛЕНЫ ИЗ БАЗЫ ДАННЫХ!
INFO:__main__:============================================================
INFO:__main__:Следующие ID начнутся с 1:
INFO:__main__: - users.id
INFO:__main__: - lotteries.id
INFO:__main__: - accounts.id
INFO:__main__: - participations.id
INFO:__main__: - winners.id
INFO:__main__: - winner_verifications.id
INFO:__main__:✅ Соединение с БД закрыто
```
## Когда использовать
### ✅ Безопасные сценарии:
- **Разработка/тестирование**: Очистка тестовых данных
- **Staging окружение**: Подготовка к тестированию
- **Миграция**: Перед полным переносом данных
- **Сброс демо**: Возврат к чистому состоянию
### ❌ НЕ использовать:
- **Production база**: НИКОГДА не запускайте на рабочей БД
- **Активные розыгрыши**: Если есть незавершенные розыгрыши
- **Без бэкапа**: Всегда делайте резервную копию перед очисткой
## Безопасность
### Файл читает DATABASE_URL из .env:
```env
DATABASE_URL=postgresql+asyncpg://user:password@host:port/database
```
### Проверка перед запуском:
```bash
# Проверьте что вы на правильной базе
grep DATABASE_URL .env
```
### Создание бэкапа (ОБЯЗАТЕЛЬНО для production):
```bash
# PostgreSQL
pg_dump -h localhost -U bot_user -d bot_db > backup_$(date +%Y%m%d_%H%M%S).sql
# Восстановление
psql -h localhost -U bot_user -d bot_db < backup_20251116_140000.sql
```
## Альтернативы
### Мягкая очистка (сохранение структуры):
```sql
-- Очистить только данные розыгрышей
DELETE FROM winners;
DELETE FROM participations;
DELETE FROM lotteries;
```
### Выборочная очистка:
```sql
-- Удалить только тестовые данные
DELETE FROM users WHERE club_card_number LIKE 'TEST%';
```
## Требования
- Python 3.12+
- Установленные зависимости (`pip install -r requirements.txt`)
- Файл `.env` с корректным `DATABASE_URL`
- Права на изменение данных в БД
## Технические детали
### Используемые библиотеки:
```python
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
```
### Асинхронное выполнение:
```python
async with engine.begin() as conn:
await conn.execute(text('DELETE FROM table_name'))
```
### Обработка транзакций:
- `engine.begin()` - автоматический COMMIT при успехе
- `engine.dispose()` - закрытие всех соединений
## Отладка
### Проблемы с подключением:
```bash
# Проверка доступности БД
psql -h 192.168.0.102 -U bot_user -d bot_db -c "SELECT 1"
```
### Проверка прав:
```sql
-- В psql
SELECT has_database_privilege('bot_user', 'bot_db', 'CONNECT');
SELECT has_table_privilege('bot_user', 'users', 'DELETE');
```
### Логи:
Скрипт использует стандартный logging с уровнем INFO. Для отладки:
```python
logging.basicConfig(level=logging.DEBUG)
```
## См. также
- `docs/ADMIN_GUIDE.md` - Руководство администратора
- `scripts/setup_postgres.sh` - Настройка PostgreSQL
- `migrations/` - Миграции базы данных
- `Makefile` - Все доступные команды

97
scripts/clear_database.py Normal file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
Скрипт для полной очистки базы данных
Удаляет все данные из всех таблиц и сбрасывает счетчики ID
"""
import asyncio
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def clear_database():
"""Очистка всех таблиц и сброс последовательностей"""
# Читаем DATABASE_URL из .env
with open('.env', 'r') as f:
for line in f:
if line.startswith('DATABASE_URL='):
DATABASE_URL = line.split('=', 1)[1].strip().strip('"').strip("'")
break
logger.info('🔌 Подключение к базе данных...')
engine = create_async_engine(DATABASE_URL)
try:
async with engine.begin() as conn:
# Отключаем ограничения внешних ключей
await conn.execute(text('SET CONSTRAINTS ALL DEFERRED'))
# Очищаем таблицы в правильном порядке (от зависимых к независимым)
tables = [
'winner_verifications',
'winners',
'participations',
'accounts',
'lotteries',
'users'
]
logger.info('')
logger.info('🗑️ Удаление данных из таблиц...')
logger.info('-' * 60)
for table in tables:
result = await conn.execute(text(f'DELETE FROM {table}'))
logger.info(f'{table:25} - удалено {result.rowcount:5} строк')
logger.info('-' * 60)
# Сбрасываем последовательности (auto-increment)
sequences = [
'users_id_seq',
'lotteries_id_seq',
'accounts_id_seq',
'participations_id_seq',
'winners_id_seq',
'winner_verifications_id_seq'
]
logger.info('')
logger.info('🔄 Сброс последовательностей...')
logger.info('-' * 60)
for seq in sequences:
try:
await conn.execute(text(f'ALTER SEQUENCE {seq} RESTART WITH 1'))
logger.info(f'{seq}')
except Exception as e:
logger.warning(f'⚠️ {seq}: {e}')
logger.info('-' * 60)
logger.info('')
logger.info('=' * 60)
logger.info('🎉 ВСЕ ДАННЫЕ УСПЕШНО УДАЛЕНЫ ИЗ БАЗЫ ДАННЫХ!')
logger.info('=' * 60)
logger.info('')
logger.info('Следующие ID начнутся с 1:')
logger.info(' - users.id')
logger.info(' - lotteries.id')
logger.info(' - accounts.id')
logger.info(' - participations.id')
logger.info(' - winners.id')
logger.info(' - winner_verifications.id')
logger.info('')
finally:
await engine.dispose()
logger.info('✅ Соединение с БД закрыто')
if __name__ == '__main__':
asyncio.run(clear_database())

View File

@@ -1,11 +1,12 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from .database import Base
import secrets
class User(Base):
"""Модель пользователя"""
"""Модель пользователя с регистрацией"""
__tablename__ = "users"
id = Column(Integer, primary_key=True)
@@ -13,16 +14,68 @@ class User(Base):
username = Column(String(255))
first_name = Column(String(255))
last_name = Column(String(255))
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
phone = Column(String(20), nullable=True) # Телефон для верификации
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию
is_admin = Column(Boolean, default=False)
# Клиентский счет в формате: XX-XX-XX-XX-XX-XX-XX (7 пар цифр через дефис)
account_number = Column(String(20), unique=True, nullable=True, index=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# Секретный код для верификации выигрыша (генерируется при регистрации)
verification_code = Column(String(10), unique=True, nullable=True)
# Связи
accounts = relationship("Account", back_populates="owner", cascade="all, delete-orphan")
participations = relationship("Participation", back_populates="user")
winners = relationship("Winner", back_populates="user")
def __repr__(self):
return f"<User(telegram_id={self.telegram_id}, username={self.username})>"
return f"<User(telegram_id={self.telegram_id}, club_card={self.club_card_number})>"
def generate_verification_code(self):
"""Генерирует уникальный код верификации"""
self.verification_code = secrets.token_hex(4).upper() # 8-символьный код
class Account(Base):
"""Модель счета клиента (может быть несколько у одного пользователя)"""
__tablename__ = "accounts"
id = Column(Integer, primary_key=True)
account_number = Column(String(20), unique=True, nullable=False, index=True) # XX-XX-XX-XX-XX-XX-XX
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
is_active = Column(Boolean, default=True) # Активен ли счет
# Связи
owner = relationship("User", back_populates="accounts")
participations = relationship("Participation", back_populates="account")
def __repr__(self):
return f"<Account(number={self.account_number}, owner_id={self.owner_id})>"
class WinnerVerification(Base):
"""Модель верификации победителя"""
__tablename__ = "winner_verifications"
id = Column(Integer, primary_key=True)
winner_id = Column(Integer, ForeignKey("winners.id"), nullable=False, unique=True)
verification_token = Column(String(32), unique=True, nullable=False) # Токен для подтверждения
is_verified = Column(Boolean, default=False)
verified_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
expires_at = Column(DateTime(timezone=True), nullable=False) # Срок действия токена
# Связи
winner = relationship("Winner", back_populates="verification")
def __repr__(self):
return f"<WinnerVerification(winner_id={self.winner_id}, verified={self.is_verified})>"
@staticmethod
def generate_token():
"""Генерирует уникальный токен верификации"""
return secrets.token_urlsafe(24)
class Lottery(Base):
@@ -60,14 +113,16 @@ class Participation(Base):
__tablename__ = "participations"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
account_number = Column(String(20), nullable=True, index=True) # Счет участника (XX-XX-XX-XX-XX-XX-XX)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True) # Привязка к счету
account_number = Column(String(20), nullable=True, index=True) # Дублируем для быстрого доступа
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# Связи
user = relationship("User", back_populates="participations")
lottery = relationship("Lottery", back_populates="participations")
account = relationship("Account", back_populates="participations")
def __repr__(self):
if self.account_number:
@@ -81,16 +136,22 @@ class Winner(Base):
id = Column(Integer, primary_key=True)
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально
account_number = Column(String(20), nullable=True, index=True) # Счет победителя
place = Column(Integer, nullable=False) # Место (1, 2, 3...)
prize = Column(String(500)) # Описание приза
is_manual = Column(Boolean, default=False) # Был ли установлен вручную
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
account_number = Column(String(20), nullable=True, index=True)
place = Column(Integer, nullable=False)
prize = Column(String(500))
is_manual = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# Статус подтверждения выигрыша
is_notified = Column(Boolean, default=False) # Отправлено ли уведомление
is_claimed = Column(Boolean, default=False) # Подтвердил ли победитель
claimed_at = Column(DateTime(timezone=True), nullable=True) # Время подтверждения
# Связи
user = relationship("User")
user = relationship("User", back_populates="winners")
lottery = relationship("Lottery")
verification = relationship("WinnerVerification", back_populates="winner", uselist=False)
def __repr__(self):
if self.account_number:

View File

@@ -0,0 +1,287 @@
"""Сервисы для регистрации, управления счетами и верификации"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from .models import User, Account, Winner, WinnerVerification
from typing import Optional, List
from datetime import datetime, timezone, timedelta
import secrets
class RegistrationService:
"""Сервис для регистрации пользователей"""
@staticmethod
async def register_user(
session: AsyncSession,
telegram_id: int,
club_card_number: str,
phone: Optional[str] = None
) -> User:
"""Зарегистрировать нового пользователя с клубной картой"""
# Проверяем, не занята ли клубная карта
existing = await session.execute(
select(User).where(User.club_card_number == club_card_number)
)
if existing.scalar_one_or_none():
raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована")
# Находим пользователя по telegram_id
result = await session.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
raise ValueError("Пользователь не найден")
# Обновляем данные пользователя
user.club_card_number = club_card_number
user.phone = phone
user.is_registered = True
user.generate_verification_code()
await session.commit()
await session.refresh(user)
return user
@staticmethod
async def get_user_by_club_card(
session: AsyncSession,
club_card_number: str
) -> Optional[User]:
"""Найти пользователя по номеру клубной карты"""
result = await session.execute(
select(User).where(User.club_card_number == club_card_number)
)
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_verification_code(
session: AsyncSession,
verification_code: str
) -> Optional[User]:
"""Найти пользователя по коду верификации"""
result = await session.execute(
select(User).where(User.verification_code == verification_code)
)
return result.scalar_one_or_none()
class AccountService:
"""Сервис для управления счетами пользователей"""
@staticmethod
async def create_account(
session: AsyncSession,
club_card_number: str,
account_number: str
) -> Account:
"""Создать новый счет для пользователя по номеру клубной карты"""
# Находим владельца по клубной карте
user_result = await session.execute(
select(User).where(User.club_card_number == club_card_number)
)
user = user_result.scalar_one_or_none()
if not user:
raise ValueError(f"Пользователь с клубной картой {club_card_number} не найден")
# Проверяем, не существует ли уже такой счет
existing = await session.execute(
select(Account).where(Account.account_number == account_number)
)
if existing.scalar_one_or_none():
raise ValueError(f"Счет {account_number} уже существует")
# Создаем счет
account = Account(
account_number=account_number,
owner_id=user.id,
is_active=True
)
session.add(account)
await session.commit()
await session.refresh(account)
return account
@staticmethod
async def get_account_owner(
session: AsyncSession,
account_number: str
) -> Optional[User]:
"""Найти владельца счета"""
result = await session.execute(
select(Account).where(
and_(
Account.account_number == account_number,
Account.is_active == True
)
)
)
account = result.scalar_one_or_none()
if not account:
return None
# Получаем владельца
owner_result = await session.execute(
select(User).where(User.id == account.owner_id)
)
return owner_result.scalar_one_or_none()
@staticmethod
async def get_user_accounts(
session: AsyncSession,
user_id: int
) -> List[Account]:
"""Получить все счета пользователя"""
result = await session.execute(
select(Account)
.where(Account.owner_id == user_id)
.order_by(Account.created_at.desc())
)
return result.scalars().all()
@staticmethod
async def deactivate_account(
session: AsyncSession,
account_number: str
) -> bool:
"""Деактивировать счет"""
result = await session.execute(
select(Account).where(Account.account_number == account_number)
)
account = result.scalar_one_or_none()
if not account:
return False
account.is_active = False
await session.commit()
return True
class WinnerNotificationService:
"""Сервис для уведомления победителей"""
@staticmethod
async def create_verification_token(
session: AsyncSession,
winner_id: int,
expires_hours: int = 24
) -> WinnerVerification:
"""Создать токен верификации для победителя"""
# Проверяем, нет ли уже токена
existing = await session.execute(
select(WinnerVerification).where(WinnerVerification.winner_id == winner_id)
)
verification = existing.scalar_one_or_none()
if verification:
# Обновляем существующий токен
verification.verification_token = WinnerVerification.generate_token()
verification.created_at = datetime.now(timezone.utc)
verification.expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
verification.is_verified = False
verification.verified_at = None
else:
# Создаем новый
verification = WinnerVerification(
winner_id=winner_id,
verification_token=WinnerVerification.generate_token(),
created_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc) + timedelta(hours=expires_hours)
)
session.add(verification)
await session.commit()
await session.refresh(verification)
return verification
@staticmethod
async def verify_winner(
session: AsyncSession,
verification_code: str,
lottery_id: int
) -> Optional[Winner]:
"""Подтвердить выигрыш по коду верификации пользователя"""
# Находим пользователя по коду
user_result = await session.execute(
select(User).where(User.verification_code == verification_code)
)
user = user_result.scalar_one_or_none()
if not user:
return None
# Находим выигрыш этого пользователя в данном розыгрыше
winner_result = await session.execute(
select(Winner).where(
and_(
Winner.user_id == user.id,
Winner.lottery_id == lottery_id,
Winner.is_claimed == False
)
)
)
winner = winner_result.scalar_one_or_none()
if not winner:
# Проверяем, может быть выигрыш по счету
# Получаем все счета пользователя
accounts_result = await session.execute(
select(Account).where(Account.owner_id == user.id)
)
accounts = accounts_result.scalars().all()
account_numbers = [acc.account_number for acc in accounts]
# Ищем выигрыш по любому из счетов
winner_result = await session.execute(
select(Winner).where(
and_(
Winner.account_number.in_(account_numbers),
Winner.lottery_id == lottery_id,
Winner.is_claimed == False
)
)
)
winner = winner_result.scalar_one_or_none()
if not winner:
return None
# Помечаем как подтвержденный
winner.is_claimed = True
# Обновляем верификацию если есть
verification_result = await session.execute(
select(WinnerVerification).where(WinnerVerification.winner_id == winner.id)
)
verification = verification_result.scalar_one_or_none()
if verification:
verification.is_verified = True
verification.verified_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(winner)
return winner
@staticmethod
async def get_unverified_winners(
session: AsyncSession,
lottery_id: int
) -> List[Winner]:
"""Получить список неподтвержденных победителей"""
result = await session.execute(
select(Winner).where(
and_(
Winner.lottery_id == lottery_id,
Winner.is_claimed == False
)
)
)
return result.scalars().all()

View File

@@ -1,7 +1,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
from .models import User, Lottery, Participation, Winner
from .models import User, Lottery, Participation, Winner, Account
from typing import List, Optional, Dict, Any
from ..utils.account_utils import validate_account_number, format_account_number
import random
@@ -251,8 +251,20 @@ class LotteryService:
if not lottery or lottery.is_completed:
return {}
# Получаем всех участников
participants = [p.user for p in lottery.participations]
# Получаем всех участников (включая тех, у кого нет user)
participants = []
for p in lottery.participations:
if p.user:
participants.append(p.user)
else:
# Создаем временный объект для участников без пользователя
# Храним только номер счета
participants.append(type('obj', (object,), {
'id': None,
'telegram_id': None,
'account_number': p.account_number
})())
if not participants:
return {}
@@ -270,7 +282,7 @@ class LotteryService:
# Находим пользователя среди участников
manual_winner = None
for participant in remaining_participants:
if participant.telegram_id == manual_winners[place_str]:
if hasattr(participant, 'telegram_id') and participant.telegram_id == manual_winners[place_str]:
manual_winner = participant
break
@@ -295,9 +307,11 @@ class LotteryService:
# Сохраняем победителей в базу данных
for place, winner_info in results.items():
user_obj = winner_info['user']
winner = Winner(
lottery_id=lottery_id,
user_id=winner_info['user'].id,
user_id=user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
account_number=user_obj.account_number if hasattr(user_obj, 'account_number') else None,
place=place,
prize=winner_info['prize'],
is_manual=winner_info['is_manual']
@@ -306,15 +320,17 @@ class LotteryService:
# Обновляем статус розыгрыша
lottery.is_completed = True
lottery.draw_results = {
str(place): {
'user_id': info['user'].id,
'telegram_id': info['user'].telegram_id,
'username': info['user'].username,
lottery.draw_results = {}
for place, info in results.items():
user_obj = info['user']
lottery.draw_results[str(place)] = {
'user_id': user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
'telegram_id': user_obj.telegram_id if hasattr(user_obj, 'telegram_id') else None,
'username': user_obj.username if hasattr(user_obj, 'username') else None,
'account_number': user_obj.account_number if hasattr(user_obj, 'account_number') else None,
'prize': info['prize'],
'is_manual': info['is_manual']
} for place, info in results.items()
}
}
await session.commit()
return results

View File

@@ -0,0 +1,585 @@
"""Админские обработчики для управления счетами и верификации"""
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select, and_
from src.core.database import async_session_maker
from src.core.registration_services import AccountService, WinnerNotificationService, RegistrationService
from src.core.services import UserService, LotteryService, ParticipationService
from src.core.models import User, Winner, Account, Participation
from src.core.config import ADMIN_IDS
router = Router()
class AddAccountStates(StatesGroup):
waiting_for_data = State()
choosing_lottery = State()
def is_admin(user_id: int) -> bool:
"""Проверка прав администратора"""
return user_id in ADMIN_IDS
@router.message(Command("add_account"))
async def add_account_command(message: Message, state: FSMContext):
"""
Добавить счет пользователю по клубной карте
Формат: /add_account <club_card> <account_number>
Или: /add_account (затем вводить данные построчно)
"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
parts = message.text.split(maxsplit=2)
# Если данные указаны в команде
if len(parts) == 3:
club_card = parts[1]
account_number = parts[2]
await process_single_account(message, club_card, account_number, state)
else:
# Запрашиваем данные
await state.set_state(AddAccountStates.waiting_for_data)
await message.answer(
"💳 **Добавление счетов**\n\n"
"Отправьте данные в формате:\n"
"`клубная_карта номер_счета`\n\n"
"**Для одного счета:**\n"
"`2223 11-22-33-44-55-66-77`\n\n"
"**Для нескольких счетов (каждый с новой строки):**\n"
"`2223 11-22-33-44-55-66-77`\n"
"`2223 88-99-00-11-22-33-44`\n"
"`3334 12-34-56-78-90-12-34`\n\n"
"❌ Отправьте /cancel для отмены",
parse_mode="Markdown"
)
async def process_single_account(message: Message, club_card: str, account_number: str, state: FSMContext):
"""Обработка одного счета"""
try:
async with async_session_maker() as session:
# Создаем счет
account = await AccountService.create_account(
session,
club_card_number=club_card,
account_number=account_number
)
# Получаем владельца
owner = await AccountService.get_account_owner(session, account_number)
# Сохраняем данные счета в state для добавления в розыгрыш
await state.update_data(
accounts=[{
'club_card': club_card,
'account_number': account_number,
'account_id': account.id
}]
)
text = f"✅ Счет успешно добавлен!\n\n"
text += f"🎫 Клубная карта: {club_card}\n"
text += f"💳 Счет: {account_number}\n"
if owner:
text += f"👤 Владелец: {owner.first_name}\n\n"
# Отправляем уведомление владельцу
try:
await message.bot.send_message(
owner.telegram_id,
f"К вашему профилю добавлен счет:\n\n"
f"💳 {account_number}\n\n"
f"Теперь вы можете участвовать в розыгрышах с этим счетом!"
)
text += "📨 Владельцу отправлено уведомление\n\n"
except Exception as e:
text += f"⚠️ Не удалось отправить уведомление: {str(e)}\n\n"
# Предлагаем добавить в розыгрыш
await show_lottery_selection(message, text, state)
except ValueError as e:
await message.answer(f"❌ Ошибка: {str(e)}")
await state.clear()
except Exception as e:
await message.answer(f"❌ Произошла ошибка: {str(e)}")
await state.clear()
@router.message(AddAccountStates.waiting_for_data)
async def process_accounts_data(message: Message, state: FSMContext):
"""Обработка данных счетов (один или несколько)"""
if message.text.strip().lower() == '/cancel':
await state.clear()
await message.answer("❌ Операция отменена")
return
lines = message.text.strip().split('\n')
accounts_data = []
errors = []
for i, line in enumerate(lines, 1):
parts = line.strip().split()
if len(parts) != 2:
errors.append(f"Строка {i}: неверный формат (ожидается: клубная_карта номер_счета)")
continue
club_card, account_number = parts
try:
async with async_session_maker() as session:
account = await AccountService.create_account(
session,
club_card_number=club_card,
account_number=account_number
)
owner = await AccountService.get_account_owner(session, account_number)
accounts_data.append({
'club_card': club_card,
'account_number': account_number,
'account_id': account.id,
'owner': owner
})
# Отправляем уведомление владельцу
if owner:
try:
await message.bot.send_message(
owner.telegram_id,
f"К вашему профилю добавлен счет:\n\n"
f"💳 {account_number}\n\n"
f"Теперь вы можете участвовать в розыгрышах!"
)
except:
pass
except ValueError as e:
errors.append(f"Строка {i} ({club_card} {account_number}): {str(e)}")
except Exception as e:
errors.append(f"Строка {i}: {str(e)}")
# Формируем отчет
text = f"📊 **Результаты добавления счетов**\n\n"
if accounts_data:
text += f"✅ **Успешно добавлено: {len(accounts_data)}**\n\n"
for acc in accounts_data:
text += f"{acc['club_card']}{acc['account_number']}\n"
if acc['owner']:
text += f" 👤 {acc['owner'].first_name}\n"
text += "\n"
if errors:
text += f"❌ **Ошибки: {len(errors)}**\n\n"
for error in errors[:5]: # Показываем максимум 5 ошибок
text += f"{error}\n"
if len(errors) > 5:
text += f"\n... и еще {len(errors) - 5} ошибок\n"
if not accounts_data:
await message.answer(text)
await state.clear()
return
# Сохраняем данные и предлагаем добавить в розыгрыш
await state.update_data(accounts=accounts_data)
await show_lottery_selection(message, text, state)
async def show_lottery_selection(message: Message, prev_text: str, state: FSMContext):
"""Показать выбор розыгрыша для добавления счетов"""
async with async_session_maker() as session:
lotteries = await LotteryService.get_active_lotteries(session)
if not lotteries:
await message.answer(
prev_text + " Нет активных розыгрышей для добавления счетов"
)
await state.clear()
return
await state.set_state(AddAccountStates.choosing_lottery)
buttons = []
for lottery in lotteries[:10]: # Максимум 10 розыгрышей
buttons.append([
InlineKeyboardButton(
text=f"🎯 {lottery.title}",
callback_data=f"add_to_lottery_{lottery.id}"
)
])
buttons.append([
InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add")
])
await message.answer(
prev_text + " **Добавить счета в розыгрыш?**\n\n"
"Выберите розыгрыш из списка:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
)
@router.callback_query(F.data.startswith("add_to_lottery_"))
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
"""Добавить счета в выбранный розыгрыш"""
lottery_id = int(callback.data.split("_")[-1])
data = await state.get_data()
accounts = data.get('accounts', [])
if not accounts:
await callback.answer("❌ Нет данных о счетах", show_alert=True)
await state.clear()
return
success_count = 0
errors = []
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
await state.clear()
return
for acc in accounts:
try:
# Добавляем участие через account_id
# Проверяем, не участвует ли уже
existing = await session.execute(
select(Participation).where(
and_(
Participation.lottery_id == lottery_id,
Participation.account_id == acc['account_id']
)
)
)
if existing.scalar_one_or_none():
errors.append(f"{acc['account_number']}: уже участвует")
continue
# Создаем участие
participation = Participation(
lottery_id=lottery_id,
account_id=acc['account_id'],
account_number=acc['account_number']
)
session.add(participation)
success_count += 1
except Exception as e:
errors.append(f"{acc['account_number']}: {str(e)}")
await session.commit()
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n"
if success_count:
text += f"✅ Добавлено счетов: {success_count}\n\n"
if errors:
text += f"⚠️ Ошибки: {len(errors)}\n"
for error in errors[:3]:
text += f"{error}\n"
await callback.message.edit_text(text)
await state.clear()
@router.callback_query(F.data == "skip_lottery_add")
async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
"""Пропустить добавление в розыгрыш"""
await callback.message.edit_text("✅ Счета добавлены без участия в розыгрышах")
await state.clear()
@router.message(Command("remove_account"))
async def remove_account_command(message: Message):
"""
Деактивировать счет
Формат: /remove_account <account_number>
"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
parts = message.text.split()
if len(parts) != 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /remove_account <account_number>"
)
return
account_number = parts[1]
try:
async with async_session_maker() as session:
success = await AccountService.deactivate_account(session, account_number)
if success:
await message.answer(f"✅ Счет {account_number} деактивирован")
else:
await message.answer(f"❌ Счет {account_number} не найден")
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("verify_winner"))
async def verify_winner_command(message: Message):
"""
Подтвердить выигрыш по коду верификации
Формат: /verify_winner <verification_code> <lottery_id>
Пример: /verify_winner AB12CD34 1
"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
parts = message.text.split()
if len(parts) != 3:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте:\n"
"/verify_winner <verification_code> <lottery_id>\n\n"
"Пример:\n"
"/verify_winner AB12CD34 1"
)
return
verification_code = parts[1].upper()
try:
lottery_id = int(parts[2])
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
try:
async with async_session_maker() as session:
# Проверяем существование розыгрыша
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
return
# Подтверждаем выигрыш
winner = await WinnerNotificationService.verify_winner(
session,
verification_code=verification_code,
lottery_id=lottery_id
)
if not winner:
await message.answer(
f"❌ Выигрыш не найден\n\n"
f"Возможные причины:\n"
f"• Неверный код верификации\n"
f"• Пользователь не является победителем в розыгрыше #{lottery_id}\n"
f"• Выигрыш уже был подтвержден"
)
return
# Получаем пользователя
user = await RegistrationService.get_user_by_verification_code(session, verification_code)
text = "✅ Выигрыш подтвержден!\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n"
text += f"🏆 Место: {winner.place}\n"
text += f"🎁 Приз: {winner.prize}\n\n"
if user:
text += f"👤 Победитель: {user.first_name}\n"
text += f"🎫 Клубная карта: {user.club_card_number}\n"
if user.phone:
text += f"📱 Телефон: {user.phone}\n"
# Отправляем уведомление победителю
try:
bot = message.bot
await bot.send_message(
user.telegram_id,
f"✅ Ваш выигрыш подтвержден!\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n\n"
f"Администратор свяжется с вами для получения приза."
)
text += "\n📨 Победителю отправлено уведомление"
except Exception as e:
text += f"\n⚠️ Не удалось отправить уведомление: {str(e)}"
if winner.account_number:
text += f"💳 Счет: {winner.account_number}\n"
await message.answer(text)
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("winner_status"))
async def winner_status_command(message: Message):
"""
Показать статус всех победителей розыгрыша
Формат: /winner_status <lottery_id>
"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
parts = message.text.split()
if len(parts) != 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /winner_status <lottery_id>"
)
return
try:
lottery_id = int(parts[1])
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
try:
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
return
winners = await LotteryService.get_winners(session, lottery_id)
if not winners:
await message.answer(f"В розыгрыше '{lottery.title}' пока нет победителей")
return
text = f"🏆 Победители розыгрыша '{lottery.title}':\n\n"
for winner in winners:
status_icon = "" if winner.is_claimed else ""
notified_icon = "📨" if winner.is_notified else "📭"
text += f"{status_icon} {winner.place} место - {winner.prize}\n"
# Получаем информацию о победителе
async with async_session_maker() as session:
if winner.user_id:
user_result = await session.execute(
select(User).where(User.id == winner.user_id)
)
user = user_result.scalar_one_or_none()
if user:
text += f" 👤 {user.first_name}"
if user.club_card_number:
text += f" (КК: {user.club_card_number})"
text += "\n"
if winner.account_number:
text += f" 💳 {winner.account_number}\n"
# Статус подтверждения
if winner.is_claimed:
text += f" ✅ Подтвержден\n"
else:
text += f" ⏳ Ожидает подтверждения\n"
text += "\n"
await message.answer(text)
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("user_info"))
async def user_info_command(message: Message):
"""
Показать информацию о пользователе
Формат: /user_info <club_card>
"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
parts = message.text.split()
if len(parts) != 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /user_info <club_card>"
)
return
club_card = parts[1]
try:
async with async_session_maker() as session:
user = await RegistrationService.get_user_by_club_card(session, club_card)
if not user:
await message.answer(f"❌ Пользователь с клубной картой {club_card} не найден")
return
# Получаем счета
accounts = await AccountService.get_user_accounts(session, user.id)
# Получаем выигрыши
winners_result = await session.execute(
select(Winner).where(Winner.user_id == user.id)
)
winners = winners_result.scalars().all()
text = f"👤 Информация о пользователе\n\n"
text += f"🎫 Клубная карта: {user.club_card_number}\n"
text += f"👤 Имя: {user.first_name}"
if user.last_name:
text += f" {user.last_name}"
text += "\n"
if user.username:
text += f"📱 Telegram: @{user.username}\n"
if user.phone:
text += f"📞 Телефон: {user.phone}\n"
text += f"🔑 Код верификации: {user.verification_code}\n"
text += f"📅 Зарегистрирован: {user.created_at.strftime('%d.%m.%Y')}\n\n"
# Счета
text += f"💳 Счета ({len(accounts)}):\n"
if accounts:
for acc in accounts:
status = "" if acc.is_active else ""
text += f" {status} {acc.account_number}\n"
else:
text += " Нет счетов\n"
# Выигрыши
text += f"\n🏆 Выигрыши ({len(winners)}):\n"
if winners:
for w in winners:
status = "" if w.is_claimed else ""
text += f" {status} {w.place} место - {w.prize}\n"
else:
text += " Нет выигрышей\n"
await message.answer(text)
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")

View File

@@ -471,9 +471,13 @@ async def show_lottery_participants(callback: CallbackQuery):
for i, participation in enumerate(lottery.participations[:20], 1): # Показываем первых 20
user = participation.user
username = f"@{user.username}" if user.username else "Нет username"
text += f"{i}. {user.first_name} {user.last_name or ''}\n"
text += f" {username} | ID: {user.telegram_id}\n"
if user:
username = f"@{user.username}" if user.username else "Нет username"
text += f"{i}. {user.first_name} {user.last_name or ''}\n"
text += f" {username} | ID: {user.telegram_id}\n"
else:
# Если пользователя нет, показываем номер счета
text += f"{i}. Счет: {participation.account_number or 'Не указан'}\n"
text += f" Участвует с: {participation.created_at.strftime('%d.%m %H:%M')}\n\n"
if len(lottery.participations) > 20:
@@ -1683,11 +1687,13 @@ async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext):
parts = callback.data.split("_")
if len(parts) == 3: # admin_edit_123
lottery_id = int(parts[2])
# Подменяем callback_data для обработки существующим хэндлером
callback.data = f"admin_edit_lottery_select_{lottery_id}"
# Напрямую вызываем обработчик вместо подмены callback_data
await state.update_data(edit_lottery_id=lottery_id)
await choose_edit_field(callback, state)
else:
# Если формат другой, то это уже правильный callback
lottery_id = int(callback.data.split("_")[-1])
await state.update_data(edit_lottery_id=lottery_id)
await choose_edit_field(callback, state)
@@ -2027,8 +2033,8 @@ async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMCont
lottery_id = int(callback.data.split("_")[-1])
# Перенаправляем на стандартный обработчик
callback.data = f"admin_choose_winner_lottery_{lottery_id}"
# Напрямую вызываем обработчик вместо подмены callback_data
await state.update_data(winner_lottery_id=lottery_id)
await choose_winner_place(callback, state)
@@ -2610,11 +2616,12 @@ async def conduct_lottery_draw(callback: CallbackQuery):
await callback.answer("Нет участников для розыгрыша", show_alert=True)
return
# Проводим розыгрыш
from ..display.conduct_draw import conduct_draw
winners = await conduct_draw(lottery_id)
# Проводим розыгрыш через сервис
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
if winners:
if winners_dict:
# Получаем победителей из базы
winners = await LotteryService.get_winners(session, lottery_id)
text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n"
text += "🏆 Победители:\n"
for winner in winners:

View File

@@ -0,0 +1,314 @@
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
from aiogram import Router, F
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command
from sqlalchemy import select, and_
from datetime import datetime, timezone, timedelta
import random
from src.core.database import async_session_maker
from src.core.registration_services import AccountService, WinnerNotificationService
from src.core.services import LotteryService
from src.core.models import User, Winner
from src.core.config import ADMIN_IDS
router = Router()
def is_admin(user_id: int) -> bool:
"""Проверка прав администратора"""
return user_id in ADMIN_IDS
@router.message(Command("check_unclaimed"))
async def check_unclaimed_winners(message: Message):
"""
Проверить неподтвержденные выигрыши (более 24 часов)
Формат: /check_unclaimed <lottery_id>
"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
parts = message.text.split()
if len(parts) != 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /check_unclaimed <lottery_id>"
)
return
try:
lottery_id = int(parts[1])
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
try:
async with async_session_maker() as session:
from sqlalchemy.orm import selectinload
from src.core.models import Lottery
# Загружаем розыгрыш с участниками
lottery_result = await session.execute(
select(Lottery)
.options(selectinload(Lottery.participations))
.where(Lottery.id == lottery_id)
)
lottery = lottery_result.scalar_one_or_none()
if not lottery:
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
return
winners = await LotteryService.get_winners(session, lottery_id)
if not winners:
await message.answer(f"В розыгрыше '{lottery.title}' нет победителей")
return
# Находим неподтвержденные выигрыши старше 24 часов
now = datetime.now(timezone.utc)
unclaimed = []
for winner in winners:
if not winner.is_claimed and winner.is_notified:
# Проверяем, прошло ли 24 часа
time_passed = now - winner.created_at
if time_passed.total_seconds() > 24 * 3600: # 24 часа
unclaimed.append({
'winner': winner,
'hours_passed': int(time_passed.total_seconds() / 3600)
})
if not unclaimed:
await message.answer(
f"Все победители розыгрыша '{lottery.title}' подтвердили выигрыш\n"
f"или срок подтверждения еще не истек."
)
return
text = f"⚠️ **Неподтвержденные выигрыши в розыгрыше '{lottery.title}':**\n\n"
for item in unclaimed:
winner = item['winner']
hours = item['hours_passed']
text += f"🏆 {winner.place} место - {winner.prize}\n"
# Получаем информацию о победителе
async with async_session_maker() as session:
if winner.user_id:
user_result = await session.execute(
select(User).where(User.id == winner.user_id)
)
user = user_result.scalar_one_or_none()
if user:
text += f" 👤 {user.first_name}"
if user.club_card_number:
text += f" (КК: {user.club_card_number})"
text += "\n"
if winner.account_number:
text += f" 💳 {winner.account_number}\n"
text += f" ⏰ Прошло: {hours} часов\n\n"
text += f"\n📊 Всего неподтвержденных: {len(unclaimed)}\n\n"
text += f"Используйте /redraw {lottery_id} для повторного розыгрыша"
await message.answer(text, parse_mode="Markdown")
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("redraw"))
async def redraw_lottery(message: Message):
"""
Переиграть розыгрыш для неподтвержденных выигрышей
Формат: /redraw <lottery_id>
"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
parts = message.text.split()
if len(parts) != 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /redraw <lottery_id>"
)
return
try:
lottery_id = int(parts[1])
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
try:
async with async_session_maker() as session:
from sqlalchemy.orm import selectinload
from src.core.models import Lottery
# Загружаем розыгрыш с участниками
lottery_result = await session.execute(
select(Lottery)
.options(selectinload(Lottery.participations))
.where(Lottery.id == lottery_id)
)
lottery = lottery_result.scalar_one_or_none()
if not lottery:
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
return
winners = await LotteryService.get_winners(session, lottery_id)
# Находим неподтвержденные выигрыши старше 24 часов
now = datetime.now(timezone.utc)
unclaimed_winners = []
for winner in winners:
if not winner.is_claimed and winner.is_notified:
time_passed = now - winner.created_at
if time_passed.total_seconds() > 24 * 3600:
unclaimed_winners.append(winner)
if not unclaimed_winners:
await message.answer(
"✅ Нет неподтвержденных выигрышей старше 24 часов.\n"
"Повторный розыгрыш не требуется."
)
return
# Получаем всех участников, исключая текущих победителей
all_participants = []
current_winner_accounts = set()
for winner in winners:
if winner.account_number:
current_winner_accounts.add(winner.account_number)
for p in lottery.participations:
if p.account_number and p.account_number not in current_winner_accounts:
all_participants.append(p)
if not all_participants:
await message.answer(
"❌ Нет доступных участников для повторного розыгрыша.\n"
"Все участники уже являются победителями."
)
return
# Переигрываем каждое неподтвержденное место
redraw_results = []
for old_winner in unclaimed_winners:
if not all_participants:
break
# Выбираем нового победителя
new_participant = random.choice(all_participants)
all_participants.remove(new_participant)
# Удаляем старого победителя
await session.delete(old_winner)
# Создаем нового победителя
new_winner = Winner(
lottery_id=lottery_id,
user_id=None,
account_number=new_participant.account_number,
account_id=new_participant.account_id,
place=old_winner.place,
prize=old_winner.prize,
is_manual=False,
is_notified=False,
is_claimed=False
)
session.add(new_winner)
redraw_results.append({
'place': old_winner.place,
'prize': old_winner.prize,
'old_account': old_winner.account_number,
'new_account': new_participant.account_number
})
await session.commit()
# Отправляем уведомления новым победителям
for result in redraw_results:
# Находим нового победителя
new_winner_result = await session.execute(
select(Winner).where(
and_(
Winner.lottery_id == lottery_id,
Winner.place == result['place'],
Winner.account_number == result['new_account']
)
)
)
new_winner = new_winner_result.scalar_one_or_none()
if new_winner:
# Отправляем уведомление новому победителю
owner = await AccountService.get_account_owner(session, new_winner.account_number)
if owner and owner.telegram_id:
# Создаем токен верификации
await WinnerNotificationService.create_verification_token(
session,
new_winner.id
)
# Формируем сообщение
notification_message = (
f"🎉 Поздравляем! Ваш счет выиграл!\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {new_winner.place}\n"
f"🎁 Приз: {new_winner.prize}\n"
f"💳 Счет: {new_winner.account_number}\n\n"
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
f"Нажмите кнопку ниже, чтобы подтвердить получение приза."
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить получение приза",
callback_data=f"confirm_win_{new_winner.id}"
)]
])
try:
await message.bot.send_message(
owner.telegram_id,
notification_message,
reply_markup=keyboard,
parse_mode="Markdown"
)
new_winner.is_notified = True
await session.commit()
except:
pass
# Формируем отчет для админа
text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n"
text += f"📊 Переиграно мест: {len(redraw_results)}\n\n"
for result in redraw_results:
text += f"🏆 {result['place']} место - {result['prize']}\n"
text += f" ❌ Было: {result['old_account']}\n"
text += f" ✅ Стало: {result['new_account']}\n\n"
text += "📨 Новым победителям отправлены уведомления"
await message.answer(text, parse_mode="Markdown")
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")

View File

@@ -0,0 +1,150 @@
"""Обработчики для регистрации пользователей"""
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from src.core.database import async_session_maker
from src.core.registration_services import RegistrationService, AccountService
from src.core.services import UserService
router = Router()
class RegistrationStates(StatesGroup):
"""Состояния для процесса регистрации"""
waiting_for_club_card = State()
waiting_for_phone = State()
@router.callback_query(F.data == "start_registration")
async def start_registration(callback: CallbackQuery, state: FSMContext):
"""Начать процесс регистрации"""
text = (
"📝 Регистрация в системе\n\n"
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
"Введите номер вашей клубной карты:"
)
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
])
)
await state.set_state(RegistrationStates.waiting_for_club_card)
@router.message(StateFilter(RegistrationStates.waiting_for_club_card))
async def process_club_card(message: Message, state: FSMContext):
"""Обработка номера клубной карты"""
club_card_number = message.text.strip()
# Проверяем, не занята ли карта
async with async_session_maker() as session:
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number)
if existing_user:
await message.answer(
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
"Если это ваша карта, обратитесь к администратору."
)
await state.clear()
return
await state.update_data(club_card_number=club_card_number)
await message.answer(
"📱 Теперь введите ваш номер телефона\n"
"(или отправьте '-' чтобы пропустить):"
)
await state.set_state(RegistrationStates.waiting_for_phone)
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
async def process_phone(message: Message, state: FSMContext):
"""Обработка номера телефона"""
phone = None if message.text.strip() == "-" else message.text.strip()
data = await state.get_data()
club_card_number = data['club_card_number']
try:
async with async_session_maker() as session:
user = await RegistrationService.register_user(
session,
telegram_id=message.from_user.id,
club_card_number=club_card_number,
phone=phone
)
text = (
"✅ Регистрация завершена!\n\n"
f"🎫 Клубная карта: {user.club_card_number}\n"
f"🔑 Ваш код верификации: **{user.verification_code}**\n\n"
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"
"Теперь вы можете участвовать в розыгрышах!"
)
await message.answer(text, parse_mode="Markdown")
await state.clear()
except ValueError as e:
await message.answer(f"❌ Ошибка регистрации: {str(e)}")
await state.clear()
except Exception as e:
await message.answer(f"❌ Произошла ошибка: {str(e)}")
await state.clear()
@router.message(Command("my_code"))
async def show_verification_code(message: Message):
"""Показать код верификации пользователя"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user or not user.is_registered:
await message.answer(
"❌ Вы не зарегистрированы в системе.\n\n"
"Для регистрации отправьте /start и выберите 'Регистрация'"
)
return
text = (
"🔑 Ваш код верификации:\n\n"
f"**{user.verification_code}**\n\n"
"Этот код используется для подтверждения выигрыша.\n"
"Сообщите его администратору при получении приза."
)
await message.answer(text, parse_mode="Markdown")
@router.message(Command("my_accounts"))
async def show_user_accounts(message: Message):
"""Показать счета пользователя"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user or not user.is_registered:
await message.answer("❌ Вы не зарегистрированы в системе")
return
accounts = await AccountService.get_user_accounts(session, user.id)
if not accounts:
await message.answer(
"У вас пока нет привязанных счетов.\n\n"
"Счета добавляются администратором."
)
return
text = f"💳 Ваши счета (Клубная карта: {user.club_card_number}):\n\n"
for i, account in enumerate(accounts, 1):
status = "" if account.is_active else ""
text += f"{i}. {status} {account.account_number}\n"
await message.answer(text)