swagger - http://localhost:8080/swagger-ui.html
Проектная практика МИФИ, магистратура
Цель:
Создание системы для проведения лотерейных тиражей с возможностью покупки билетов, определения выигрышей и интеграции с платежными системами.
Язык программирования
- Java 17+.
Фрейворк
- Spring Boot 3.x
База данных
- PostgreSQL 17
Архитектура
- Монолит с четким разделением слоев: Controller → Service → Repository.
Безопастность
- Аутентификация через JWT-токены
Роли пользователей
- USER - покупка билетов, проверка результатов, история своих покупок и результатов тиражей
- ADMIN - управление тиражами, играми, отмена тиражей. Дополнительно должен иметь доступ к статистике и отчётам по всем пользователям.
Основные поля
- id
- role
- telegram
- balance
Опционально
- username
- password
- created_at
- is_active
- referal_link
Примечание: Логин и пароль для аутентификации и выдачи токена лучше, наверное, сделать. Roles можно вынести в отдельную таблицу
Описание: Тиражная служба управляет лотерейными тиражами, их статусами и расписанием. Она отвечает за создание, изменение и отмену тиражей, а также за автоматическое определение результатов.
Сущности:
Draw (Тираж):
- id (уникальный идентификатор).
- name (имя или сквозной номер тиража).
- lottery_type_id (тип лотереи, например, "5 из 36").
- start_time (дата и время начала тиража).
- duration (длитеь ность тиража в статусe Active).
- status (статус):
- PLANNED - тираж создан, но ещё не начался, можно покупать билеты,
- ACTIVE - тираж активен, можно покупать билеты,
- COMPLETED - тираж завершен, результаты определены,
- CANCELLED - тираж отменен.
DrawResult (Результат тиража):
- id.
- draw_id (ссылка на тираж).
- winning_combination (выигрышная комбинация чисел или ).
- result_time (время определения результатов).
Примечание: может сюда положить призового фонда, ведь она фиксируется по результату тиража по сумме проданных билетов, а также добавить JSON поле с массивом/словарем выигравших билетов
- prize_pool (сумма призового фонда = сумма ьилетов тиража * prize_pool_percentage)
- winning_tickets (например: {"winnners": [{id1:[combination1, prize_amount]}, {id2:[combination2, prize_amount]} ]}, ...])
LotteryType (Тип лотереи):
- id.
- description (или name - описание/имя типа лотерии, например, "5 из 36").
- ticket_price (стоимость билета для этого типа лотерии).
- min_ticket(минимальное кол-во билетов для тиража, иначе он не состоится).
- prize_pool_percentage (% от суммы проданных всех билетов, который формирует сумму призового фонда)
API:
- POST /api/admin/draws (только ADMIN): Создание тиража с указанием типа лотереи и времени старта.
- GET /api/draws/active: Получение списка активных тиражей.
- GET /api/draws/available: тиражи, в которых можно купить билеты - выборка по статусам ACTIVE + PLANNED).
- PUT /api/admin/draws/{id}/cancel (только ADMIN): Отмена тиража (изменение статуса на CANCELLED).
- GET /api/draws/{id} - детальная информация о тираже.
- GET /api/draws/{id}/results - результаты тиража.
Логика:
- Автоматический переход статусов:
- При наступлении startTime статус меняется с PLANNED на ACTIVE.
- По завершении времени тиража статус меняется на COMPLETED, запускается алгоритм определения выигрышей.
- При отмене тиража все связанные билеты помечаются как недействительные.
- Отмена должна быть возможна как для PLANNED, так и для ACTIVE тиражей. Для COMPLETED тиражей отмена недопустима.
- Добавьте возможность просматривать историю завершенных тиражей
- API для получения информации о предыдущих тиражах и их результатах.
Описание: Служба генерации билетов отвечает за создание билетов для участия в тиражах. Поддержка двух режимов генерации:
- Предсозданные билеты: Система генерирует билеты автоматически «на лету» (для лотерей, где выбор чисел запрещен).
- Пользовательский выбор: Пользователь самостоятельно выбирает числа (например, для лотереи "5 из 36").
Сущности:
Ticket (Билет):
- id.
- user_id (идентификатор пользователя).
- draw_id (идентификатор тиража).
- Data описание данных билета в JSON: например {"numbers":[1, 5, 10, 15, 20]}
- status (статус: PENDING, WIN, LOSE, CENCELLED). Примечание: PENDING переименовать в INGAME
API:
- GET /api/tickets/{id} (только USER): Получение информации о билете (числа, статус, тираж).
Логика:
- Гененрировать билеты только "на лету" по запросу:
- не хранить непроданые билеты в БД
- по запросу генерировать новый случайный билет в соответствии с типом лотерии
- после оплаты сохранять билет в БД с привязкой к пользователю
- до оплаты билет имеет только временную привязку в InvoiceData
Логика автоматической лотереи: генерируем id билета при продаже, а при определении результата выбираем из сгенерированных id случайным образом - только 1 победитель
- Валидация чисел:
- Для лотереи "5 из 36": 5 уникальных чисел в диапазоне 1–36.
- При невалидных данных возвращается ошибка 400 Bad Request.
- При создании билета проверяется, что тираж в статусе PLANNED или ACTIVE.
- Добавьте возможность пользователям просматривать историю своих билетов и их статусы.
- GET /api/tickets API для получения списка всех билетов пользователя с учетом тиражной информации.
Описание: Шлюз оплаты отвечает за обработку платежей за покупку билетов с интеграцией с mock-платежной системой.
Для MVP: 1 invoice = 1 ticket По сути, инфойс - корзина для временного билета Инвойс нельзя удалить - только отменить
Сущности:
Invoice (инвойс):
- id.
- user_id - пользователь, создавший инвойс,
- ticket_data данные по билету(JSON из сущности tiket) - для будущего масштабирования нужно сделать массив, чтобы в последствии класть в 1 инвойс несколько билетов.
- register_time – время регистрации.
- payment_link - сгенерированная бэкэндом (платёжным шлюзом) ссылка на оплату
- status:
- UNPAID - инвойс создан, но не оплачен - по умолчанию
- PENDING – ожидает оплаты, в этом статусе нельзя отменить
- PAID - инвойс оплачен
- REFUNDED - средства возвращены
- cancelled. 0/1 - инвойс отменен, не может быт ьоплачен. Если был оплачен - нужно/можно сделать refund - по-умолчанию - 0.
Payment (Платеж):
- id.
- invoice_id - вторичный ключ-ссылка на инвойс
- amount (сумма платежа).
- status (SUCCESS, FAILED).
- payment_time (время выполнения платежа).
API:
- POST /api/invoice - зарегестрировать invoice. Параметры: Invoice – инвойс к регистрации, Осуществляет регистрацию инвойса
- POST /api/payments - зарегестрировать платёж (только USER). Параметры: cardNumber (номер карты), cvc (CVC-код). Симулирует успешную (80%) или неудачную (20%) оплату. Mock-сервис на этом же сервере - принимает запрос на оплату отдаёт результат success/failed
Логика:
- При оплате инвойса делаем проверку в каком статусе тираж (по данным в билете) - если Active - все ок, иначе оплатить нельзя.
- Запрос на оплату (POST /api/payments) "блокирует" инвойс - переводит в статусс PENDING
- Если инвойс в статусе PENDING – его нельзя отменить. Если оплата прошла – он переходит в статус PAID, если оплата не удалась – возвращается в статус UNPAID
- По окончании тиража все неоплаченные инвойсы становятся cancelled.
- Для тестовой среды - для MVP делаем валидацию запроса только по CVC:
- Любой номер карты принимается, если CVC = 123.
- При успешной оплате билет привязывается к пользователю.
- При неудаче билет не создается Инвойс возвращается в исходное состояние.
Описание: Служба определения результатов отвечает за генерацию выигрышных комбинаций и обновление статусов билетов.
Логика:
- После завершения тиража (status = COMPLETED):
- Генерация выигрышной комбинации с использованием SecureRandom.
- Сравнение чисел билетов с выигрышной комбинацией – для билетов типа «5 из 36».
- Определение победителей по правилам лотереи и расчет сумм выигрышей:
- Для предсозданных билетов: один победитель (полн. совпадение или рандом)
- Для лотерей с выбором чисел: если в категории несколько победителей, призовой фонд категории делится поровну, или в % соотношении по кол-ву совпавших в билете чисел.
- Результаты расчета пишем в DrawResults в winning_tickets
- Обновление статусов билетов (WIN/LOSE).
- Добавьте возможность пользователям просматривать историю своих билетов и их статусы.
Алгоритмы для разных типов лотерей:
- Лотереи с предсозданными билетами: Проверка полного совпадения чисел.
- Лотереи с выбором пользователя: Проверка частичного совпадения (например, 3 из 5 чисел).
API:
- GET /api/draws/{id}/results: Получение выигрышной комбинации тиража.
- GET /api/tickets/{id}/check-result (только USER): Проверка результата билета.
- GET /api/draws/{id}/winners - получение списка победителей тиража.
Описание: Формирование отчетов по завершенным тиражам и начисление выигрышей.
Логика: Большинство данных уже будут к этому моменту в DrawResults в winning_tickets После определения результатов тиража:
- Начисление выигрышей на счета пользователей – внутренний баланс модели USER.
- Выгрузка данных о выигрышных билетах в формате CSV/JSON.
- Отчет должен содержать:
- Идентификатор тиража.
- Выигрышную комбинацию.
- Список выигравших билетов с суммами.
- Добавьте возможность выгрузки статистики по пользователям, выигрышам и тиражам в виде отчетов (например, по месяцам) в формате csv/json
Производительность (тестируем через Постман):
- Время ответа API не более 0.5 секунды.
- Поддержка до 1000 запросов в секунду
Безопасность:
- Данные платежей (карты) не хранятся в системе.
- JWT-токены с сроком действия 1 час.
Логирование:
- Уровни логирования:
- INFO: Регистрация создания тиражей, успешных платежей.
- WARN: Попытки доступа к запрещенным ресурсам – ошибки авторизации.
- ERROR: Сбои при оплате, ошибки генерации результатов.
- Формат логов:
- JSON-логи для интеграции с системами мониторинга (ELK, Grafana Loki).
- Обязательные поля: timestamp, service, level, message, user_id (если есть).
- Ротация логов:
- Настройка ежедневной ротации лог-файлов с архивированием (например, через Logback).
Масштабируемость:
- Использование интерфейсов для алгоритмов определения результатов
- Стратегия для разных правил розыгрыша
- Абстрактная фабрика для создания билетов разных типов
- Подготовка к возможному разделению на микросервисы в будущем
REST API:
- Формат запросов/ответов: JSON.
База данных:
- Схема должна быть нормализована (2NF).
- Индексы для ускорения поиска по drawId, userId, status.
Интеграция с мессенджерами или с почтой:
- Отправка результатов тиражей через Telegram Bot API.
- Email-уведомления: Шаблоны писем для победителей (например, через SendGrid).
- Уведомления администраторов о критических ошибках.
- Интеграционные тесты: коллекция для postman.
Сценарии:
- Покупка билета с успешной/неудачной оплатой.
- Отмена тиража с проверкой статусов билетов.
- Генерация выигрышной комбинации для разных типов лотерей.
- Вывод всех билетов пользователя
Деплой:
- Развертывание в Docker-контейнере.
- Настройка подключения к PostgreSQL через переменные окружения.
Документация:
- README с инструкцией по запуску и примеры запросов.
Примечание: Архитектура должна обеспечивать модульность и легкость поддержки.
История операций:
- Эндпоинт /api/users/me/history для просмотра покупок и выигрышей.
Возврат средств:
- API для отмены билета (если тираж отменен).
Реферальная система:
- Бонусы за приглашение друзей (опционально).
Основная идея
Сервис тиражей работает на очередях отложенных задач, которые ниже будут называться планировщиками. Основаны они на ScheduledExecutorService. Один планировщик (executorPlanned) предназначен для запланированных тиражей, статус которых PLANNED. Второй (executorActive) - для активных тиражей, статус которых ACTIVE. Если тираж запланирован на какое-то время, то создаётся задача с отложенным временем запуска T = startTime - currentTime и передаётся в executorPlanned. Когда эта задача запускается, то она меняет статус тиража из PLANNED на ACTIVE и создаёт задачу для executorActive с отложенным запуском T=(startTime+duration)-currentTime. Когда задача из executorActive запускается, то она меняет статус тиража из ACTIVE на COMPLETED. Все планировщики содержат в себе тиражи только на текущие сутки, во избежания перегрузки лишними потоками системы. В начале каждых суток запускается отдельная задача (с помощью @Scheduled(cron = "0 0 0 * * ?")), которая пробегает по всем тиражам и заносит нужные тиражи в соответствующие планировщики.
Работа сервиса тиражей делится на три основные фазы:
- проверка тиражей при запуске
- обработка запроса создания тиража
- актуализация тиражей в начале новых суток
Проверка тиражей при запуске
- При запуске из таблицы Draw вычитываются все тиражи со статусом ACTIVE с датой соответствующей текущей дате и ранее. Список отсортирован от самых старых дат до самых свежих.
- Если (startTime + duration) <= текущего времени, то тираж помечается как COMPLETED.
- Если (startTime + duration) > текущего времени, то тираж помещается в executorActive.
- Далее вычитываются все тиражи со статусом PLANNED с датой соответствующей текущей дате и ранее. Список отсортирован от самых старых дат до самых свежих.
- Если (startTime + duration) <= текущего времени, тираж просрочен и помечается как CANCELLED.
- Если (startTime) <= текущего времени, тираж помечается как ACTIVE и отправляется в executorActive.
- Если (startTime) > текущего времени, тираж отправляется в executorPlanned.
- Конец.
Обработка запроса на создание тиража
- Если дата запроса тиража в будущем, но не в текущие сутки, то просто помещаем регистрируем тираж в БД как PLANNED.
- Если тираж в текущие сутки, и сейчас нету подобного тиража с таким же типом лотереи в статусе ACTIVE или PLANNED, то он помещается в executorPlanned и регистрируется в БД.
- Если тираж с таким же типом лотерии в статусе ACTIVE или PLANNED, то не регистрируем такой тираж и возвращаем ошибку.
- Конец.
Актуализация тиражей в начале новых суток
- В начале новых суток запускается задача, которая осуществляет проверку, описанную выше в "Проверка тиражей при запуске".
- Конец
Сервис реализует продажу лотерейных билетов через инвойс-ориентированный подход:
- Билет создаётся только после успешной оплаты.
- До этого — билет существует в виде черновика внутри инвойса.
- Инвойс содержит все данные для генерации билета: тираж, числа (если пользователь их указывал), статус оплаты и ссылка на оплату.
- Сервис генерации билетов использует паттерн Стратегия через
TicketsFactory, чтобы создавать билеты разных типов в зависимости от правил лотереи. - Генерация билетов происходит "на лету" (по запросу) либо из предсозданного пула (для
FixedPool-лотерей). После генерации билет временно резервируется через инвойс ("корзину"). После успешной оплаты инвойса сохраняется в БД.
| Сущность | Назначение |
|---|---|
| Ticket | Сохранённый билет в БД после оплаты |
| Invoice | Временный контейнер для черновика билета до оплаты |
| Draw | Тираж лотереи, содержит правила генерации билетов |
| TicketGenerator | Интерфейс для реализации разных стратегий создания билетов |
| TicketsFactory | Фабрика, выбирающая нужный TicketGenerator по правилам тиража |
POST /api/invoices
Authorization: Bearer <token>
Content-Type: application/json
{
"drawId": 123,
"numbers": [5, 10, 15, 20, 45] // необязательно
}- Пользователь отправляет запрос на создание инвойса с указанием
drawId. - Сервис получает тираж (
Draw) и проверяет его статус (должен быть активным). - Через
TicketsFactoryвыбирается подходящий генератор (TicketGenerator) по типу лотереи:
RandomUniqueTicketGeneratorFixedPoolTicketGeneratorUserSelectedTicketGenerator
- Генерируется черновик билета (draft):
- Если указаны
numbers→ проверяются на валидность (кол-во, диапазон, уникальность) - Иначе → числа генерируются автоматически
- Данные билета сохраняются в поле
ticketDataинвойса в формате JSON:{ "drawId": 123, "numbers": [5, 10, 15, 20, 45] } - Инвойс сохраняется в БД со статусом
UNPAID.
Эта реализация пока не используется напрямую в контроллерах, но она полностью готова к интеграции с реальным платёжным сервисом.
Создать защищённую ссылку на оплату, которая:
- Содержит данные инвойса (в том числе черновик билета)
- Защищена цифровой подписью (HMAC-SHA256)
- Может быть использована внешней платежной системой
- Позволяет вернуться к сервису с подтверждением оплаты
После создания инвойса (Invoice) в него записывается:
ticketData— JSON-представление черновика билета (TicketCreateDto)userId— пользователь, создавший инвойсstatus = UNPAID
Метод: PaymentLinkGenerator.generatePaymentLink(TicketInInvoiceDto ticketInInvoice)
-
Объект
TicketInInvoiceDtoсериализуется в JSON:{ "userId": 1, "drawId": 123, "numbers": [5, 10, 15, 20, 45], "registerTime": "2025-04-05T12:00:00", "ticketPrice": 100.0 } -
JSON кодируется в Base64 URL-safe:
String encodedData = Base64.getUrlEncoder().encodeToString(jsonData.getBytes(StandardCharsets.UTF_8));
-
Вычисляется подпись методом HMAC-SHA256 от JSON-строки:
String signature = hmacSHA256(jsonData, SECRET_KEY);
-
Формируется финальная ссылка:
https://mock-payment.com/pay?data=<encodedData>&signature=<signature>
https://mock-payment.com/pay?
data=eyJ1c2VySWQiOjEsImRyYXdJZCI6MTIzLCJub3VtYmVycyI6WzUsIDEwLCAxNSwgMjAsIDQ1XSwicmVnaXN0ZXJUaW1lIjoiMjAyNS0wNC0wNVQxMjowMDowMCIsInRpY2tldFByaWNlIjoxMDB9
& signature=a1b2c3d4e5f67890...Когда пользователь завершает оплату и система получает callback от платёжного сервиса:
- Извлекается
dataиsignatureиз параметров запроса. - Данные из
dataдекодируются из Base64. - Пересчитывается HMAC-SHA256 от декодированных данных.
- Сравнивается с переданной подписью.
- Если совпадают → данные корректны и не изменялись.
- Если нет → выбрасывается ошибка.
| Участник | Что знает |
|---|---|
| Пользователь | Получает ссылку, переходит по ней, но не видит содержимого |
| Бэкенд | Знает секретный ключ, может генерировать и проверять подписи |
| Платёжная система | Видит только data и signature, не знает структуры |
Этот механизм позволяет:
- Изолировать логику оплаты от основного сервиса.
- Гарантировать целостность данных: нельзя подменить
drawIdили цену в запросе. - Расширять архитектуру в будущем:
- Можно добавить другие типы данных в
TicketInInvoiceDto - Поддерживать разные платёжные системы
- Легко менять алгоритм подписи
- Можно добавить другие типы данных в
POST /api/payments
Authorization: Bearer <token>
Content-Type: application/json
{
"invoiceId": 789,
"cardNumber": "123456789",
"cvc": "123"
}- Сервис получает уведомление о результате оплаты.
- Если статус
SUCCESS:
- Инвойс переводится в статус
PAID. - Из
ticketDataизвлекаются данные черновика. - На основе этих данных создаётся полноценный билет (
Ticket) и сохраняется в БД.
- Если статус
FAILED:
- Инвойс остаётся в статусе
UNPAID. - Билет не создаётся.
-
Посмотреть свои билеты:
GET /api/tickets Authorization: Bearer <token>
-
Посмотреть билет по ID (только свой):
GET /api/tickets/{id} Authorization: Bearer <token>
- Посмотреть все билеты:
GET /api/tickets Authorization: Bearer <admin_token>
- Числа генерируются случайно.
- Пример: "5 из 36".
- Используется
RandomUniqueTicketGenerator.
- Билеты создаются заранее в пуле.
- При активации тиража вызывается
initPoolForDraw(). - При запросе берётся первый доступный билет из пула.
- Если пул пуст — создаётся новый билет.
- Пользователь сам выбирает числа.
- Пример: "6 из 45".
- Перед созданием проверяются:
- Количество чисел
- Диапазон
- Уникальность
- Стратегия (
TicketGenerator)
Поддержка разных типов лотерей через интерфейсTicketGenerator. Позволяет гибко расширять систему новыми стратегиями. - Фабрика (
TicketsFactory)
Централизованное создание нужногоTicketGeneratorв зависимости от правил тиража (AlgorithmRules). - DTO / Mapper
Чёткое разделение слоёв: данные передаются через DTO, маппинг между ними и сущностями осуществляется через MapStruct. - JSON-поле (
ticketData)
Используется для временного хранения черновика билета внутри инвойса до оплаты. Это позволяет не сохранять билет в БД, пока он не оплачен.
- JWT (JSON Web Token)
Используется для аутентификации и авторизации пользователей. Все защищённые эндпоинты требуют токен с соответствующими правами. - Spring Security
Гибкая система ограничений по ролям: разграничение доступа междуUSER,ADMINи анонимными пользователями. - Валидация
Валидация данных происходит:- На уровне DTO — с использованием аннотаций JSR 380
- На уровне бизнес-логики — проверка диапазона чисел, количества, уникальности, статуса тиража и т. д.
- Механизм защищённой ссылки на оплату
- Реализован способ создания безопасной подписываемой ссылки, включающий шифрование данных билета и подпись через HMAC-SHA256 — обеспечивает защиту целостности данных при взаимодействии с внешней платёжной системой.
| Ситуация | Что происходит |
|---|---|
| Нехватка билетов в пуле | Для FixedPool — генерируется новый билет динамически |
| Отмена тиража | Все связанные инвойсы отменяются, билеты не создаются |
| Ошибка валидации чисел | Возвращается 400 BadRequest |
| Оплата не прошла | Инвойс остается в статусе UNPAID, билет не сохраняется |
| Инвойс уже оплачен | Нельзя повторно обработать оплату |
sequenceDiagram
participant User
participant Backend
participant InvoiceService
participant TicketGenerator
participant PaymentService
User->>Backend: POST /api/invoices {drawId, numbers}
Backend->>InvoiceService: создать инвойс
InvoiceService->>TicketGenerator: выбрать генератор
TicketGenerator-->>InvoiceService: generateTicket()
InvoiceService->>InvoiceService: сохранить draft в ticketData
InvoiceService-->>Backend: invoice + paymentLink
Backend-->>User: 201 Created + paymentLink
User->>PaymentService: POST /api/payments {invoiceId, status}
PaymentService->>InvoiceService: обновить статус инвойса
alt status == SUCCESS
InvoiceService->>TicketService: создать билет из ticketData
TicketService-->>InvoiceService: сохранить билет
end
- Сервис гибко поддерживает разные типы лотерей через стратегии.
- Билеты создаются только после оплаты.
- Инвойс служит временным контейнером для черновика билета.
- Реализована защищённая ссылка на оплату, но пока не используется.
- Просто добавлять новые типы лотерей — достаточно реализовать
TicketGenerator.