Реализация многораундовой аукционной системы на Node.js + TypeScript, основанная на механике Telegram Gift Auctions.
📺 Для жюри конкурса: см.
DEMO.md— демо-материалы, видео, работающий сайт (https://youtu.be/_YQOnP4ZYN0) (https://auction-production-cfa9.up.railway.app/)
# Запустить MongoDB + API + Workers
docker compose -f docker-compose.full.yml up --buildПосле запуска контейнеров:
# Создать демо-данные
npm install
npm run seed:demoГотово! Откройте http://localhost:3000
# 1. MongoDB
docker compose up -d
# 2. Установить зависимости
npm install
# 3. Запустить все сервисы (API + Workers)
npm run start:all
# 4. Создать демо-данные (в другом терминале)
npm run seed:demoГотово! Откройте http://localhost:3000
- API Server (порт 3000) — REST API + UI
- Worker — автоматическое закрытие раундов
- Reconcile Worker — проверка финансовых инвариантов
- MongoDB (порт 27017) — replica set для транзакций
- Mongo Express (порт 8081) — веб-интерфейс БД
Команда npm run seed:demo создаёт:
- 5 тестовых аккаунтов с балансами
- Демо-аукцион с автоучастниками
- Запускает аукцион автоматически
Локально (Windows):
pip install -r contest-auction/deploy/requirements.txt
python contest-auction/deploy/deploy.pyФинансовая надёжность:
- ✅ Reconcile Worker — автоматическая проверка и исправление финансовых инвариантов
- ✅ Compensating Transactions — восстановление после ошибок capture/release
- ✅ Optimistic Locking — retry механизм для конкурентных операций
- ✅ Hold/Release/Capture модель для безопасной работы со ставками
Безопасность (10/10):
- ✅ XSS Protection — экранирование всех пользовательских данных в UI
- ✅ Rate Limiting — 2000 запросов за 1 минуту с одного IP
- ✅ CORS — настроенная политика межсайтовых запросов
- ✅ Helmet — защитные HTTP-заголовки (CSP, XSS, MIME)
- ✅ Input Validation — Zod-схемы для всех API endpoints
Надёжность и наблюдаемость:
- ✅ 55 тестов — покрытие критических сценариев
- ✅ Prometheus метрики — мониторинг ставок и конфликтов
- ✅ Structured logs (JSON) — удобная диагностика
- ✅ MongoDB транзакции — атомарность операций
Механика:
- ✅ Multi-round аукционы — открытая система, новые участники могут войти в любой раунд
- ✅ Multi-lot — несколько призов в одном аукционе (
totalLots = lotsCount × maxRounds) - ✅ Anti-sniping — продление раунда только при конкурентных ставках (обгон лидера)
- ✅ Carry-over ставок — ставки автоматически переносятся между раундами
- ✅ Открытая система — участники могут входить в любой раунд без ограничений
- ✅ Tie-breaking — честная конкуренция при равных ставках (раньше = лучше)
- ✅ Идемпотентность — безопасные retry операций
Можно создать .env (см. .env.example) или не создавать — тогда используются дефолты для локальной Mongo.
cd contest-auction
npm install
npm run devПо умолчанию npm run dev запускает API и встроенный scheduler (inline worker), который автоматически закрывает раунды.
Если вы запускаете отдельный воркер, отключите inline worker:
cd contest-auction
# вариант 1
WORKER_INLINE=0 npm run dev
# вариант 2 (аналогично)
WORKER_EXTERNAL=1 npm run devТесты (55 тестов):
cd contest-auction
npm testПокрытие критических сценариев:
- Финансовые операции (hold/release/capture)
- Конкурентность и race conditions
- Eligibility и отсев участников
- Anti-sniping и продления раундов
- Финализация аукционов
- Multi-lot механика
UI: GET http://localhost:3000/
MongoDB настроен как single-node replica set (rs0) для поддержки транзакций. При первом запуске контейнер автоматически инициализирует replica set через mongo-init.js. Connection string включает ?replicaSet=rs0.
Проверка статуса replica set:
docker exec -it contest-auction-mongo mongosh --eval "rs.status()"UI: обновлённый тёмный интерфейс (mobile-first), полностью на русском.
По умолчанию UI в User Mode (минимализм). Dev Mode включает Dev Panel.
- query:
/?dev=true - hotkey:
Ctrl+Shift+D - кнопка
Devв header (сохраняется в localStorage)
-
Откройте UI:
GET http://localhost:3000/ -
В блоке Управление:
- задайте Пользователь (ник) и сохраните;
- (опционально) пополните баланс;
- в разделе Создать аукцион заполните параметры и нажмите Создать и запустить.
-
Для участников можно скопировать ссылку (кнопка Скопировать ссылку) — Dev Mode не нужен.
-
Ставка делается через кнопку Сделать ставку (bottom-sheet): можно вводить сумму вручную, использовать +1 шаг / +2 шага / Максимум, ошибки показываются понятным языком.
В отдельном процессе:
cd contest-auction
npm run workerЛоги воркера пишутся в stdout в JSON (удобно для сборщиков логов/grep).
После сборки:
cd contest-auction
npm run build
npm run start:workerReconcile worker проверяет консистентность финансовых операций и автоматически исправляет обнаруженные проблемы:
- Проверка инварианта
total = available + heldдля всех аккаунтов - Обнаружение зависших holds после завершения аукционов
- Проверка соответствия holds активным ставкам
- Автоматическое исправление (auto-fix) для безопасных случаев
- Создание записей для ручного разбора (manual review) для сложных случаев
Запуск:
cd contest-auction
npm run reconcileИнтервал проверки по умолчанию: 5 минут (настраивается через RECONCILE_INTERVAL_MS).
Модели:
ReconcileIssue— запись об обнаруженной проблеме с деталями и статусом (detected/resolved/manual_review)
Compensating transactions:
- При ошибках capture/release в финализации автоматически создаются записи ReconcileIssue
- Reconcile worker пытается исправить их при следующем запуске
- После 3 неудачных попыток issue переходит в статус manual_review
Health-check: GET http://localhost:3000/health
Prometheus: GET http://localhost:3000/metrics
ВАЖНО: Аукцион имеет ФИКСИРОВАННОЕ количество призов:
totalLots = lotsCount × maxRounds(общее количество призов)- В каждом раунде раздается
lotsCountпризов - Аукцион завершается когда розданы ВСЕ
totalLotsпризы - Участники без приза просто проигрывают (это нормально)
Пример:
lotsCount = 3(призов в раунде)maxRounds = 4(максимум раундов)totalLots = 3 × 4 = 12(всего призов)- Раунд 1: 3 победителя, остальные продолжают
- Раунд 2: 3 победителя, остальные продолжают
- Раунд 3: 3 победителя, остальные продолжают
- Раунд 4: 3 победителя, аукцион завершен
- Всего роздано: 3+3+3+3 = 12 призов ✅
ВАЖНО: Открытая система раундов — новые участники могут войти в любой раунд.
Переход раунда:
- Каждый раунд имеет фиксированную длительность (
roundDurationSec) - После закрытия раунда топ-
lotsCountучастников получают приз и ВЫБЫВАЮТ - Все остальные участники продолжают со своими ставками (carry-over)
- Новые участники могут войти в следующий раунд
- Аукцион завершается когда розданы все
totalLotsпризы
Ранжирование участников:
- Максимальная ставка за ВСЕ раунды (desc)
- Время первой ставки с этой суммой (asc) — tie-breaker
Финализация:
Аукцион завершается автоматически когда lotsDistributed >= totalLots:
- Победители раундов уже получили capture (списание) при закрытии раунда
- Оставшимся участникам возвращается hold (release)
- Статус меняется на
finished
Важно: Ставки автоматически переносятся между раундами:
- Участник делает ставку в раунде 1 → hold резервирует средства
- Переходит в раунд 2 → ставка сохраняется автоматически (carry-over)
- Участник НЕ обязан делать новую ставку в каждом раунде
- Используется максимальная ставка по всем раундам для ранжирования
- Повышает ставку → hold увеличивается на дельту
- Выигрывает → hold конвертируется в capture (списание)
- Проигрывает в раунде → hold возвращается (release)
Преимущества:
- ✅ Простота для участников — не нужно повторять ставку каждый раунд
- ✅ Справедливость — все ставки учитываются независимо от раунда
- ✅ Соответствует логике Telegram Gift Auctions
Механизм продления раунда: Раунд продлевается автоматически при выполнении всех условий:
- Ставка сделана в окне anti-sniping (
snipingWindowSecсекунд до конца) - Ставка конкурентная — обгоняет текущего лидера раунда
- Не исчерпан лимит продлений (
maxExtensionsPerRound)
ВАЖНО: Продление происходит ТОЛЬКО при конкурентных ставках, которые обгоняют текущего лидера. Это защищает от бесконечного продления раунда спамом микроставок.
Конкурентная ставка:
- Новая ставка должна быть больше ставки текущего лидера раунда
- Микроставки без обгона лидера НЕ продлевают раунд
- Защита от спама и манипуляций
Формула продления:
new_endsAt = max(current_endsAt, now) + extendBySec
Пример:
snipingWindowSec = 10(последние 10 секунд раунда)extendBySec = 10(продлить на 10 секунд)maxExtensionsPerRound = 5(максимум 5 продлений)
Атомарность: Продление происходит в той же транзакции, что и запись ставки.
Ключевые поля:
lotsCount— количество призов в каждом раундеmaxRounds— максимальное количество раундовtotalLots— общее количество призов (lotsCount × maxRounds)winners— массивparticipantIdпобедителей (заполняется после финализации)winningBids— массив{ participantId, amount }с финальными ставками победителей
Пример:
lotsCount = 3(3 приза в каждом раунде)maxRounds = 4(максимум 4 раунда)totalLots = 12(всего 12 призов)- После 4 раундов роздано 12 призов → финализация
GET /auctions/:id— возвращаетwinnersиwinningBidsдля завершённых аукционовGET /api/auction/my-wins— получение выигрышей текущего участникаPOST /auctions/:id/cancel— отменяет аукцион и возвращает все hold'ы участникам
UI: если таймер дошёл до 0с, но сервер ещё не закрыл раунд (например, из-за задержки/продления) — вместо «0с» показывается «Синхронизация…» и UI сам делает рефетч.
Требуется установленный k6 (без Grafana/Prometheus):
choco install k6- Поднять Mongo:
cd contest-auction
docker compose up -d- Поднять API (в одном терминале):
cd contest-auction
npm install
npm run dev- Поднять worker (в другом терминале):
cd contest-auction
npm run worker- Прогнать нагрузку (stdout + файл с summary):
cd contest-auction
API_BASE_URL=http://localhost:3000/api \
DURATION_SEC=60 STEADY_VUS=20 SPIKE_VUS=50 SPIKE_LAST_SEC=10 \
PARTICIPANTS=100 DEPOSIT_AMOUNT=100000 MIN_INCREMENT=10 \
npm run loadAnti-sniping режим (интенсивные ставки в последние секунды + проверка продления roundEndsAt):
cd contest-auction
API_BASE_URL=http://localhost:3000/api MODE=anti_sniping \
ANTI_ROUND_DURATION_SEC=20 ANTI_SNIPING_WINDOW_SEC=10 ANTI_SNIPING_EXTEND_SEC=10 ANTI_SNIPING_MAX_EXTENDS=10 \
DURATION_SEC=30 STEADY_VUS=10 SPIKE_VUS=50 SPIKE_LAST_SEC=8 \
PARTICIPANTS=100 DEPOSIT_AMOUNT=100000 MIN_INCREMENT=10 LOTS_COUNT=1 \
npm run load:snipingМожно использовать существующий аукцион:
cd contest-auction
API_BASE_URL=http://localhost:3000/api AUCTION_ID=<auctionId> \
DURATION_SEC=60 STEADY_VUS=20 SPIKE_VUS=50 SPIKE_LAST_SEC=10 \
PARTICIPANTS=100 DEPOSIT_AMOUNT=100000 MIN_INCREMENT=10 \
npm run loadРезультаты: stdout; также сохраняется JSON-отчёт в contest-auction/load/summary.json.
Запуск (сервер + worker в отдельных терминалах, затем боты):
cd contest-auction
npm run devcd contest-auction
npm run workercd contest-auction
API_BASE_URL=http://localhost:3000/api \
BOTS=50 CONCURRENCY=30 DURATION_SEC=60 \
npm run botsМожно указать существующий аукцион:
cd contest-auction
API_BASE_URL=http://localhost:3000/api AUCTION_ID=<auctionId> \
BOTS=50 CONCURRENCY=30 DURATION_SEC=60 \
npm run botsСерверная настройка автозапуска:
cd contest-auction
# включить по умолчанию для новых/активных аукционов
BOTS_AUTOSTART=1 \
BOTS_AUTOSTART_STRATEGY=calm \
BOTS_AUTOSTART_COUNT=20 \
BOTS_AUTOSTART_TICK_MS=900 \
npm run devПоддерживаемые стратегии: calm / aggressive.
Логика ботов:
- Бот перебивает человека максимум 1 раз за весь аукцион
- Боты могут перебивать друг друга без ограничений
- Защита от доминирования ботов над живыми участниками
В UI (User Mode) при создании аукциона есть переключатель «Автоучастники: Вкл/Выкл». В Dev Mode отображаются расширенные параметры (режим/количество/интервал) — они сохраняются в документе аукциона.
База: http://localhost:3000/api
curl -X POST "http://localhost:3000/api/accounts/u1/deposit" \
-H "content-type: application/json" \
-d '{"amount":"1000","currency":"RUB"}'Проверка баланса/hold:
curl "http://localhost:3000/api/accounts/u1?currency=RUB"curl -X POST "http://localhost:3000/api/auctions" \
-H "content-type: application/json" \
-d '{
"code":"A-001",
"title":"Gift auction demo",
"currency":"RUB",
"roundDurationSec":30,
"minIncrement":"10",
"topK":3,
"snipingWindowSec":10,
"extendBySec":10,
"maxExtensionsPerRound":10
}'curl -X POST "http://localhost:3000/api/auctions/<auctionId>/start"curl -X POST "http://localhost:3000/api/auctions/<auctionId>/bids" \
-H "content-type: application/json" \
-d '{"participantId":"u1","amount":"100","idempotencyKey":"u1-r1-1"}'curl "http://localhost:3000/api/auctions/<auctionId>?leaders=10"curl "http://localhost:3000/api/auctions/<auctionId>/rounds/1/leaderboard?limit=10"curl -X POST "http://localhost:3000/api/auctions/<auctionId>/rounds/close"cd contest-auction
npm run build
npm run start