Live Feed
1. Summary
Goal: Система real-time ленты событий для отображения активности всех пользователей платформы. Использует Server-Sent Events (SSE) для push-уведомлений о новых событиях.
User Value: Социальное взаимодействие через наблюдение за успехами других игроков. FOMO-эффект: видя, как другие получают редкие предметы, пользователь мотивирован быть активнее.

2. Business Logic
Event Types
- CASE_OPENED
- SPIN_WON
- ITEM_CRAFTED
- ITEM_WITHDRAWN
- ACHIEVEMENT_UNLOCKED
- REFERRAL_BONUS
- RAFFLE_WIN
Триггер: Пользователь открыл кейс
Фильтрация: Только награды TIER_1+ или Scrap >= 500 или XP >= 1000
Broadcast: С задержкой 9.5 секунд (синхронизация с анимацией барабана)
Триггер: Пользователь выиграл на рулетке
Фильтрация: Только награды TIER_1+ или Scrap >= 500 или XP >= 1000
Broadcast: Мгновенный (без задержки)
Триггер: Пользователь скрафтил предмет (скин)
Фильтрация: Всегда показываем (финальный скин — значимое событие)
Триггер: Трейд-оффер Steam принят (скин выведен)
Фильтрация: Всегда показываем
Триггер: Пользователь разблокировал достижение
Фильтрация: Всегда показываем
Триггер: Пользователь получил бонус за реферала
Фильтрация: Всегда показываем
Триггер: Пользователь выиграл в еженедельном розыгрыше
Фильтрация: Всегда показываем
Интеграция с raffle.service пока не реализована.
Core Mechanics
1. Фильтрация событий
Не все события попадают в ленту — показываем только значимые:
| Тип награды | Порог |
|---|---|
| ITEM | TIER_1 и выше |
| SCRAP | >= 500 |
| XP | >= 1000 |
Пороги выбраны для создания "вау-эффекта". Мелкие награды (ресурсы, 10 скрапа) не попадают в ленту, чтобы не размывать восприятие редких дропов.
2. Анонимизация
Для защиты приватности username частично скрывается:
@alexandr → @ale***
@jo → @j****
@max → @ma***
3. Broadcast с задержкой
Для событий CASE_OPENED используется задержка 9.5 секунд — это соответствует длительности анимации барабана на фронтенде. Событие появится в ленте других игроков только после завершения анимации у автора.
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| Get feed | - | none | GetFeedQuerySchema |
| Live stream | - | none | - |
Feed endpoints не требуют авторизации — лента доступна всем. Это осознанное решение для привлечения новых пользователей (показать активность до регистрации).
Edge Cases
Что видит пользователь (UI):
| Ситуация | UI поведение |
|---|---|
| 📴 SSE disconnect | Клиент автоматически переподключается |
| 🔄 Initial load | Загружается 10 последних событий |
| 🎰 Анимация кейса | Событие появится через 9.5 сек после открытия |
Backend Error Codes (для API/тестов)
| Код | HTTP | Описание |
|---|---|---|
| - | 200 | SSE stream (text/event-stream) |
| - | 200 | JSON response для GET /feed |
Feed не возвращает ошибок — это read-only публичный endpoint.
3. ADR (Architectural Decisions)
Почему SSE, а не WebSockets?
Проблема: Нужны real-time обновления ленты.
Решение: Server-Sent Events (SSE) — односторонний поток от сервера к клиенту.
Альтернативы (отклонены):
- WebSockets — избыточно для одностороннего потока, сложнее инфраструктурно
- Long Polling — неэффективно, создаёт нагрузку на сервер
Последствия:
- Простая реализация через нативный HTTP
- Автоматическое переподключение браузером
- Heartbeat каждые 30 секунд для поддержания соединения
Почему фильтрация по порогам?
Проблема: При высокой активности лента заспамится мелкими событиями (10 Scrap, ресурсы).
Решение: Показываем только значимые события: TIER_1+ предметы, крупные суммы Scrap/XP.
Альтернативы (отклонены):
- Показывать всё — лента превратится в мусор
- Клиентская фильтрация — лишний трафик, нагрузка на клиента
Последствия:
- Чистая лента с "вау-моментами"
- FOMO-эффект усиливается (видны только редкие события)
- Пороги настраиваются в
feed.constants.ts
Почему EventEmitter, а не Redis Pub/Sub?
Проблема: Нужен механизм broadcast новых событий всем SSE-подписчикам.
Решение: Node.js EventEmitter — все подписчики в одном процессе.
Альтернативы (отклонены):
- Redis Pub/Sub — нужно для multi-node, но у нас single instance
- PostgreSQL NOTIFY — излишне для in-memory broadcast
Последствия:
- Работает только при single-node deployment
- При масштабировании потребуется Redis Pub/Sub
Почему создание событий внутри транзакции?
Проблема: FeedEvent должен создаваться только если основное действие успешно (открытие кейса, крафт).
Решение: feedService.createEvent(tx, data) вызывается внутри Prisma $transaction callback.
Принципы интеграции:
- Внутри транзакции — гарантия атомарности
- После основной логики — сначала действие, потом событие
- С проверкой фильтра — для CASE_OPENED/SPIN_WON проверяем shouldCreateForReward
Последствия:
- При rollback транзакции событие не создаётся
- Broadcast происходит после commit (через EventEmitter)
- Консистентность данных гарантирована
Почему гибридный подход (специализированные таблицы + FeedEvent)?
Проблема: Нужны и детальная аналитика по типам событий, и быстрый Live Feed.
Решение: Две системы таблиц:
- Специализированные (
CaseOpening,SpinResult,CraftHistory,Withdrawal) — детальные данные для аналитики - Универсальная (
FeedEvent) — денормализованная лента для быстрого чтения
Альтернативы (отклонены):
- Только специализированные таблицы — JOIN 4+ таблиц для ленты, медленно
- Только FeedEvent — потеря детализации для аналитики
Последствия:
- Live Feed: 1 запрос, быстрая пагинация
- Аналитика: детальные данные с типизацией
- Дублирование данных, но атомарность через
$transaction
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| FeedService | backend/src/domains/feed/services/feed.service.ts | Создание и получение событий |
| FeedController | backend/src/domains/feed/controllers/feed.controller.ts | HTTP + SSE handlers |
| FeedRoutes | backend/src/domains/feed/routes/feed.routes.ts | Регистрация endpoints |
| feedEventEmitter | backend/src/domains/feed/services/feed.service.ts | EventEmitter для broadcast |
| filter utils | backend/src/domains/feed/utils/feed-filter.utils.ts | Логика фильтрации по порогам |
| anonymize utils | backend/src/domains/feed/utils/anonymize.utils.ts | Анонимизация username |
| constants | backend/src/domains/feed/constants/feed.constants.ts | Пороги и настройки |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| FeedEvent | Событие в ленте | userId, eventType, rewardId, itemId, caseId, spinId, achievementId, referralId, amount |
Relationships
Constants
| Константа | Значение | Описание |
|---|---|---|
MIN_ITEM_TIER | TIER_1 | Минимальный tier для показа |
MIN_SCRAP_AMOUNT | 500 | Минимальный Scrap для показа |
MIN_XP_AMOUNT | 1000 | Минимальный XP для показа |
FEED_MAX_EVENTS | 10 | Начальная загрузка |
FEED_THROTTLE_MS | 2000 | Интервал между событиями |
FEED_RETENTION_DAYS | 30 | Хранение в БД |
FEED_BROADCAST_DELAY_MS | 9500 | Задержка для CASE_OPENED |
6. API Endpoints
- User API
Query Parameters
| Параметр | Тип | Default | Описание |
|---|---|---|---|
limit | number | 10 | Количество событий (1-50) |
cursor | string | - | Cursor для пагинации |
SSE Message Format
// Initial load
{ type: "init", events: FeedEvent[] }
// New event
{ type: "new", event: FeedEvent }
Пример ответа FeedEvent
{
"id": "clxxx...",
"userId": "clyyy...",
"eventType": "CASE_OPENED",
"amount": null,
"createdAt": "2024-01-01T12:00:00Z",
"user": {
"id": "clyyy...",
"displayName": "@ale***"
},
"reward": {
"id": "clzzz...",
"type": "ITEM",
"amount": null,
"item": {
"id": "claaa...",
"name": "Dragon Rocket Launcher",
"imageUrl": "https://...",
"tier": "TIER_4",
"itemType": "SKIN"
}
},
"item": null,
"case": {
"id": "clbbb...",
"name": "Daily Case",
"imageUrl": "https://..."
},
"achievement": null
}
7. Related
- Cases — источник CASE_OPENED событий
- Daily Spins — источник SPIN_WON событий
- Craft — источник ITEM_CRAFTED событий
- Withdrawals — источник ITEM_WITHDRAWN событий
- Achievements — источник ACHIEVEMENT_UNLOCKED событий
- Referrals — источник REFERRAL_BONUS событий
- Raffle — источник RAFFLE_WIN событий