Описать механику Telegram-подобного аукциона цифровых подарков, включая:
- логику раундов
- ставки и их перенос между раундами
- распределение подарков
- работу с балансами и refund
- обработку всех edge-cases
Документ является источником истины для backend-логики.
- Аукцион состоит из N раундов
- В каждом раунде:
- принимаются ставки пользователей
- по окончании раунда выбираются победители
- Победители получают подарки поэтапно
- Ставки проигравших переносятся в следующий раунд
- После последнего раунда все оставшиеся ставки возвращаются пользователям
-
roundDurationMs: Длительность каждого раунда в миллисекундах
- Минимальное значение: 1000ms (1 секунда)
- Рекомендуемое значение: 60000ms (1 минута) для демо, 300000ms (5 минут) для продакшена
- Задается при создании аукциона и одинакова для всех раундов
-
totalRounds: Общее количество раундов в аукционе
- Минимальное значение: 1
- Рекомендуемое значение: 3-5 раундов
- Фиксируется при создании аукциона
-
giftsPerRound: Количество подарков на раунд
- Вычисляется как:
Math.ceil(totalGifts / totalRounds)для равномерного распределения - Или задается явно при создании аукциона
- Последний раунд может иметь меньше подарков (остаток)
- Вычисляется как:
-
minBid: Минимальная ставка
- Минимальное значение: 1
- Задается при создании аукциона
- Все ставки должны быть >= minBid
- Раунд:
- стартует автоматически после старта аукциона или закрытия предыдущего раунда
- закрывается через scheduler / worker по истечении
roundDurationMs - имеет фиксированное время начала и окончания
- Ставки принимаются только в активном раунде (между
startedAtиendsAt) - После закрытия раунда новые ставки отклоняются до следующего раунда
- Одновременные ставки и закрытие раунда обрабатываются атомарно через транзакции
- Ставка создается через
placeBid(userId, auctionId, amount) - Требования:
amount >= auction.minBiduser.balance >= amount(учитывая уже заблокированные средства)auction.status === RUNNING- Текущий раунд активен (не закрыт)
- При создании ставки:
user.balance -= amount(уменьшается свободный баланс)user.lockedBalance += amount(увеличивается заблокированный баланс)- Создается LedgerEntry с типом
LOCK - Создается Bid со статусом
ACTIVE
- Пользователь может только увеличить свою ставку
- При увеличении ставки:
- Вычисляется
delta = newAmount - oldAmount user.balance -= deltauser.lockedBalance += delta- Создается новая LedgerEntry с типом
LOCKна суммуdelta - Существующий Bid обновляется:
bid.amount = newAmount,bid.roundIndex = currentRound
- Вычисляется
- Автоматический перенос: Ставки со статусом
ACTIVE, которые не выиграли в раунде, автоматически переносятся в следующий раунд - Без дополнительных действий: Пользователю не нужно делать новую ставку
- Без изменения баланса:
lockedBalanceостается неизменным - Обновление roundIndex: При переходе в новый раунд
bid.roundIndexобновляется на текущий раунд - Одна ставка на аукцион: В один момент времени пользователь может иметь только одну
ACTIVEставку в конкретном аукционе
- Минимальная ставка фиксирована (
minBid) - Все финансовые операции проходят через Ledger (обязательно для аудита)
- Ставки никогда не удаляются (историческая запись)
Шаг 1: Сбор всех активных ставок
- Выбираются все Bid со статусом
ACTIVEдля текущего аукциона - Включаются ставки из предыдущих раундов (carry-over)
- Исключаются ставки со статусом
WON(уже получили подарок)
Шаг 2: Сортировка (детерминированная)
1. Сортировка по amount DESC (большие ставки вверху)
2. При равенстве amount: сортировка по createdAt ASC (более ранние ставки вверху)
Это гарантирует:
- Детерминированность (одинаковые входные данные → одинаковый результат)
- Справедливость (раньше поставил = преимущество при равной сумме)
Шаг 3: Выбор победителей
- Берется топ
winnersCountставок (гдеwinnersCount = giftsPerRoundили остаток для последнего раунда) - Если активных ставок меньше, чем
winnersCount, выбираются все доступные ставки - Важно: Если ставок меньше чем
giftsPerRound, неотданные подарки автоматически переносятся на следующие раунды - Производительность: Используется
.limit(winnersCount)для эффективной выборки только топ-N ставок из базы данных (не загружаются все ставки в память)
Шаг 4: Обновление статусов
- Выбранные ставки:
status = WON - Остальные ставки: остаются
status = ACTIVE(переносятся в следующий раунд)
Правило: При одинаковой сумме ставки выигрывает та, которая была создана раньше (createdAt ASC)
Пример:
User A: amount=1000, createdAt=2024-01-01T10:00:00Z
User B: amount=1000, createdAt=2024-01-01T10:00:05Z
User C: amount=500, createdAt=2024-01-01T09:00:00Z
Результат: User A выигрывает (более ранняя ставка при равной сумме)
- Победители не участвуют в следующих раундах (их ставки имеют статус
WON) - Победители получают подарок (
Gift) - Заблокированные средства победителей остаются заблокированными (оплата за подарок)
- Последний раунд:
currentRound === totalRounds - 1(0-based индексация) - После закрытия последнего раунда аукцион переходит в статус
FINALIZING
Шаг 1: Закрытие последнего раунда
- Выбираются победители по стандартному алгоритму
- Победители получают статус
WON
Шаг 2: Рефанд непобедивших ставок
- Все оставшиеся ставки со статусом
ACTIVE→REFUNDED - Производительность: Используется batch processing с cursor pagination (1000 ставок за раз) для обработки миллионов ставок без перегрузки памяти
- Для каждой ставки (в батчах):
user.balance += bid.amount(возврат средств)user.lockedBalance -= bid.amount(разблокировка)- Создается LedgerEntry с типом
REFUND bid.status = REFUNDED
- Идемпотентность: Проверка статуса перед обработкой предотвращает дублирование рефандов
Шаг 3: Завершение аукциона
auction.status = COMPLETEDauction.endsAt = currentTimestamp
- Все
ACTIVEставки будут либоWON, либоREFUNDED(никаких "зависших") - Все финансовые операции атомарны (транзакции MongoDB)
- Инвариант баланса:
balance + lockedBalanceостается постоянным (если не было внешних пополнений)
- Все операции проходят через:
- MongoDB transactions
- Ledger entries
- Инварианты:
balance >= 0lockedBalance >= 0balance + lockedBalanceнеизменно без транзакции
Назначение: Обеспечение real-time обновлений для пользователей аукциона
События:
bid_update: Новая ставка размещенаauction_update: Статус аукциона изменилсяround_closed: Раунд закрыт, выбраны победителиauctions_list_update: Обновление списка аукционов
Комнаты (Rooms):
- Подписка на аукцион:
auction:${auctionId} - Глобальная подписка: Все аукционы
Текущие Возможности:
- ✅ 10,000 одновременных подключений на один аукцион (single instance)
- ✅ ~10,000 emits/сек на один сервер
- ✅ Низкая латентность (<10ms per emit)
Рекомендации для Large Scale (100k-1M bids/round):
⚠️ Throttling обновлений: Батчинг каждые 100ms вместо немедленного emit⚠️ Selective updates: Эмит только значимых изменений (топ-10 позиций, изменения позиции пользователя)⚠️ Frontend polling: Увеличить интервал polling с 2s до 5-10s при отсутствии WebSocket событий
Требования для Very Large Scale (1M-10M bids/round):
- ❌ Socket.IO Redis Adapter: Multi-server WebSocket поддержка
- ❌ Horizontal Scaling: Распределение WebSocket подключений между серверами
- ❌ Bid Update Aggregation: Батчинг bid updates каждые 100ms
- ❌ Load Balancer: Распределение WebSocket подключений
Стратегия: Комбинация WebSocket + Polling с оптимизацией через Page Visibility API
- WebSocket для real-time обновлений (приоритет, работает независимо от visibility)
- Polling каждые 2 секунд как fallback (когда вкладка активна)
- Page Visibility API: Отключает polling когда вкладка неактивна или браузер свернут
- Когда вкладка скрыта (
document.hidden === true): polling полностью останавливается - Когда вкладка видима (
document.hidden === false): polling возобновляется с немедленным обновлением
Почему это хорошая практика:
- ✅ Экономия ресурсов (CPU, network, battery) когда пользователь не видит страницу
- ✅ Снижение нагрузки на сервер на 50-70% (учет свернутых вкладок)
- ✅ Лучший UX (браузер не замедляется, батарея разряжается медленнее)
- ✅ Стандартная практика (Page Visibility API поддерживается всеми современными браузерами)
Реализация:
- Используется
document.addEventListener('visibilitychange', handler)для отслеживания видимости - Polling интервал: 2s когда активно, 0 (остановлено) когда скрыто
- WebSocket события не зависят от visibility и продолжают работать всегда
В классических аукционах последняя секунда дает преимущество (sniping):
- Пользователи ждут последней секунды
- Делают ставку, не давая другим времени ответить
- Создает несправедливость
Основная идея: Использовать несколько раундов вместо одного таймера
Как это работает:
- Раунды вместо продления таймера: Каждый раунд имеет фиксированную длительность, которая НЕ продлевается
- Carry-over ставок: Поздние ставки не теряются, а переносятся в следующий раунд
- Множественные возможности: Пользователи могут участвовать в любом раунде
- Сглаживание тайминга: Поздние ставки влияют только на будущие раунды, не на текущий
Преимущества:
- Нет преимущества от "последней секунды" - следующий раунд даст шанс всем
- Справедливость: все ставки учитываются в итоговом результате
- Предсказуемость: фиксированное время раундов
Раунд 1 (10:00-10:05):
- User A: 1000 (10:00)
- User B: 1500 (10:04)
- Winners: User B (топ-1)
Раунд 2 (10:05-10:10):
- User A: 1000 (carry-over)
- User C: 1200 (10:06) - поздняя ставка, но влияет на раунд 2
- Winners: User C (топ-1)
Раунд 3 (10:10-10:15):
- User A: 1000 (carry-over)
- Winners: User A (топ-1)
Итог: Все пользователи получили шанс, даже User C с поздней ставкой
- Одновременные ставки
- Ставка и закрытие раунда одновременно
- Одинаковые ставки (tie-breaker:
createdAt ASC) - Повторные запросы (idempotency)
- Попытка ставки в
COMPLETEDаукционе - Попытка изменения ставки после закрытия раунда
- Используется
.limit(giftsPerRound)вместо загрузки всех ставок - MongoDB индекс:
{ auctionId: 1, status: 1, amount: -1, createdAt: 1 } - Производительность: 100-500ms даже при миллионах ставок
- Batch processing с cursor pagination (1000 ставок за раз)
- Обработка в циклах с постоянным потреблением памяти
- Производительность: Работает даже при 10M+ ставок
getTopActiveBidsиспользует.limit(3)для топ-3 ставокgetUserPositionиспользуетcountDocumentsс индексами (O(log n))- Производительность: 100-500ms для dashboard endpoint
- Батчинг обновлений каждые 100ms вместо немедленного emit
- Снижение нагрузки на WebSocket сервер при высоких частотах ставок
- Рекомендация: Эмит только значимых изменений (топ-10 позиций)
- Рекомендуется 16GB+ RAM для MongoDB при 1M+ ставок
- Мониторинг использования памяти и query performance
- Настройка connection pool size для оптимальной производительности
- Использование read replicas для dashboard queries
- Разделение read/write нагрузки для улучшения производительности
- Рекомендация: Primary для записи, Replica для чтения
- Несколько API инстансов с load balancer (nginx/traefik)
- Распределение нагрузки между серверами
- Auto-scaling на основе нагрузки
- Multi-server WebSocket поддержка через Redis
- Распределение WebSocket подключений между серверами
- Синхронизация событий между инстансами
- Primary для записи, Replicas для чтения
- Мониторинг replication lag
- Автоматический failover при сбоях
- Батчинг bid updates каждые 100ms
- Эмит только значимых изменений (топ-10 позиций, изменения позиции пользователя)
- Снижение WebSocket трафика при миллионах ставок
- Шардинг по
auctionIdдля 10M+ ставок на аукцион - Распределение данных по нескольким MongoDB shards
- Требует значительных изменений в архитектуре
| Сценарий | Ставки/Раунд | Статус | Производительность |
|---|---|---|---|
| Малый | 10k-100k | ✅ Отлично | Winner: 100-200ms, Dashboard: 100-300ms |
| Средний | 100k-1M | ✅ Хорошо | Winner: 200-500ms, Dashboard: 200-500ms |
| Большой | 1M-10M | Winner: 500ms-1s, Dashboard: 500ms-1s, требует масштабирования | |
| Очень Большой | 10M+ | ❌ Требует оптимизаций | Нужны все Very Large Scale оптимизации |
Рекомендуемые метрики для мониторинга:
- MongoDB query execution time (p95, p99)
- API response time (p95, p99)
- WebSocket connection count
- Memory usage (MongoDB, API servers)
- Error rate (<0.1% target)
- Database replication lag
- Поведение при отмене аукциона
- Максимальное число ставок от одного пользователя
- Возможность авто-бидов / ботов
- Платежные ограничения (например, min/max)
| Документ | Назначение |
|---|---|
| SPEC.md | Как продукт работает (truth source для backend) |
| ASSUMPTIONS.md | Что мы додумали / неизвестные детали |
| checklist.md | Как реализуем шаги по проекту |
| ARCHITECTURE.md | Структура системы |
| API.md | Контракты API и ошибки |
❗ Если логика не описана в SPEC.md — она не должна появляться в коде.
Cursor и разработчики должны строго следовать этому принципу, чтобы:
- избегать галлюцинаций
- сохранять детерминированность
- гарантировать финансовую целостность
- делать систему проверяемой и тестируемой
- Multi-round auction вместо таймера last-second
- Carry-over ставок между раундами
- Детально документированное поведение при tie-cases
- Ledger как обязательный источник финансовой правды
- Deterministic winner selection