Ad Banners
Система рекламных баннеров на главном экране TMA с аналитикой показов и кликов.
1. Summary
Goal: Монетизация приложения через показ рекламных баннеров партнёров с детальной аналитикой эффективности.
User Value:
- Информация о партнёрских акциях и событиях
- Ненавязчивый UX с возможностью скрыть баннеры на 30 минут
- Плавная анимация слайдера с автоповоротом
Business Value:
- Партнёрские размещения (CPC/CPM модели)
- Детальная аналитика: Total impressions, Unique views, CTR, Frequency
- Система дедупликации для честной аналитики
2. Business Logic
Display & Rotation
Слайдер на главном экране:
- Показывается под балансом пользователя (HomeScreen)
- Автоповорот: 15 секунд по умолчанию или per-banner
displaySeconds - Макс. 5 активных баннеров одновременно
- Swipe-навигация + dots indicator
Закрытие:
- Кнопка X скрывает все баннеры на 30 минут (localStorage)
- После истечения срока — автоматически появляются снова
VIEW Tracking
VIEW засчитывается когда баннер становится видимым в слайдере (активный слайд).
Дедупликация: 15 минут на пользователя.
Механика:
- Слайд становится активным (
currentIndexизменяется) - Frontend ждёт 16ms (debounce для плавности)
- Проверка: VIEW для этого баннера ещё не был записан в текущей сессии (клиентский Set)
- Отправка
POST /banners/:id/view - Backend проверяет: был ли VIEW от этого пользователя за последние 15 минут
- При успехе: создаётся запись в
BannerAnalyticsсaction = VIEW
Результат:
- Один VIEW на баннер за просмотр слайдера (клиентская защита)
- Повторный VIEW возможен через 15 минут (серверная защита)
CLICK Tracking
CLICK засчитывается когда пользователь переходит по ссылке баннера (открытие в новом окне).
Дедупликация: 1 час на пользователя.
Механика:
- Пользователь кликает по баннеру
window.open(linkUrl, '_blank')открывает ссылку- Frontend детектирует переход через:
window.blurevent (основной метод)- Fallback: проверка
document.hiddenчерез 3 секунды
- Отправка
POST /banners/:id/click - Backend проверяет: был ли CLICK от этого пользователя за последний час
- При успехе: создаётся запись в
BannerAnalyticsсaction = CLICK
Результат:
- Один CLICK на баннер в час (защита от случайных повторных кликов)
Metrics System
- Total Metrics
- Unique Metrics
- Calculated Metrics
Total Impressions / Total Clicks
Подсчитывают все события из таблицы BannerAnalytics.
SELECT COUNT(*)
FROM banner_analytics
WHERE banner_id = ? AND action = 'VIEW'
Когда использовать:
- Общая активность кампании
- Оценка frequency (сколько раз показали одному пользователю)
Unique Views / Unique Clicks
Подсчитывают уникальных пользователей через COUNT(DISTINCT userId).
SELECT COUNT(DISTINCT user_id)
FROM banner_analytics
WHERE banner_id = ? AND action = 'VIEW'
Когда использовать:
- Охват аудитории (reach)
- Расчёт realCTR (честный CTR на уникальных пользователях)
realCTR, totalCTR, Frequency
Рассчитываются на основе Total и Unique метрик.
| Метрика | Формула | Описание |
|---|---|---|
| realCTR | (uniqueClicks / uniqueViews) * 100 | CTR на уникальных пользователях |
| totalCTR | (totalClicks / totalImpressions) * 100 | CTR на всех событиях |
| frequency | totalImpressions / uniqueViews | Среднее кол-во показов на пользователя |
Пример:
- totalImpressions: 1000, uniqueViews: 200 → frequency = 5.0 (каждый видел баннер ~5 раз)
- uniqueClicks: 10, uniqueViews: 200 → realCTR = 5%
Technical Details
Интервалы дедупликации
Определены в backend/src/domains/banners/types/banner.types.ts:52:
export const DEDUPLICATION_INTERVALS = {
VIEW: 15 * 60 * 1000, // 15 минут в миллисекундах
CLICK: 60 * 60 * 1000 // 1 час в миллисекундах
}
Почему разные интервалы?
- VIEW: 15 минут — баланс между честной аналитикой и UX (пользователь может зайти снова)
- CLICK: 1 час — защита от случайных повторных кликов, но не слишком агрессивная
Frontend tracking delays
Определены в frontend/src/config/banner.config.ts:
export const BANNER_TIMING = {
VIEW_TRACKING_DELAY: 16, // 16ms debounce для VIEW
CLOSE_ANIMATION: 300, // 300ms анимация закрытия
}
export const BANNER_SLIDER = {
SLIDE_INTERVAL: 15_000, // 15 секунд автоповорот
FADE_DURATION: 300, // 300ms fade анимация
MAX_BANNERS: 5, // Макс. 5 баннеров
}
VIEW_TRACKING_DELAY (16ms):
- Предотвращает запись VIEW при быстром свайпе через баннер
- 16ms ≈ 1 frame при 60 FPS (плавная анимация)
Формулы метрик с защитой от деления на ноль
Из banner-analytics.service.ts:584-600:
private calculateBannerMetrics(rawMetrics: RawMetricsData): BannerMetrics {
const { totalViews, totalClicks, uniqueViews, uniqueClicks } = rawMetrics;
return {
totalImpressions: totalViews,
totalClicks: totalClicks,
uniqueViews: uniqueViews,
uniqueClicks: uniqueClicks,
// Безопасный расчет CTR
realCTR: uniqueViews > 0
? Number(((uniqueClicks / uniqueViews) * 100).toFixed(2))
: 0,
totalCTR: totalViews > 0
? Number(((totalClicks / totalViews) * 100).toFixed(2))
: 0,
// Frequency (среднее количество показов на пользователя)
frequency: uniqueViews > 0
? Number((totalViews / uniqueViews).toFixed(2))
: 0
};
}
Все деления защищены проверкой на ноль.
Edge Cases
| Ситуация | Поведение | Код | UI |
|---|---|---|---|
| ❌ Неавторизован | VIEW/CLICK не записывается | USER_NOT_AUTHENTICATED | Баннер показан, но нет аналитики |
| ⏱️ VIEW дубликат (< 15 мин) | Не записывается | DUPLICATE_VIEW_WITHIN_15MIN | Нет видимого эффекта |
| ⏱️ CLICK дубликат (< 1 час) | Не записывается | DUPLICATE_CLICK_WITHIN_1HOUR | Ссылка открывается, но нет аналитики |
| 🗄️ Database error | Fallback: возвращает recorded: false | DATABASE_ERROR | Ошибка не блокирует UX |
| 🔄 Повторный заход | VIEW засчитывается снова (через 15 мин) | — | Честная аналитика повторных визитов |
| ✅ Успешная запись | Запись создана в БД | recorded: true | — |
Если пользователь не авторизован (userId отсутствует), баннеры показываются, но VIEW/CLICK не записываются в аналитику.
Это предотвращает "мусор" в аналитике от ботов и тестов.
3. ADR (Architectural Decisions)
Почему дедупликация на уровне БД, а не кэша?
Проблема: Нужно предотвратить накрутку аналитики при повторных показах/кликах.
Решение: Проверка через Prisma запрос в таблицу BannerAnalytics:
await prisma.bannerAnalytics.findFirst({
where: {
bannerId,
userId,
action,
createdAt: { gte: cutoffTime }
}
})
Альтернативы (отклонены):
- Redis cache — отклонено, добавляет зависимость, сложность, риск рассинхронизации
- In-memory Map — отклонено, не работает при горизонтальном масштабировании
- Клиентский localStorage — отклонено, легко обходится через DevTools
Последствия:
- ✅ Простота: один источник правды (PostgreSQL)
- ✅ Надёжность: ACID гарантии
- ⚠️ Производительность: +1 SELECT запрос на каждый VIEW/CLICK
- Оптимизация: индекс
(bannerId, userId, action, createdAt)
- Оптимизация: индекс
Почему разные интервалы для VIEW (15 мин) и CLICK (1 час)?
Проблема: Баланс между честной аналитикой и UX.
Решение:
- VIEW: 15 минут — пользователь может зайти в приложение несколько раз за час (утром, в обед, вечером) — это легитимные визиты
- CLICK: 1 час — защита от случайных повторных кликов (double-click, случайное касание)
Последствия:
- ✅ Честная аналитика: один пользователь = несколько VIEW за день (если заходит многократно)
- ✅ Защита от накруток: случайные клики не дублируются
- ⚠️ Trade-off: если пользователь кликнул, передумал, кликнул снова через 30 минут — второй клик не засчитается
Почему удалены кэшированные счётчики из модели AdBanner?
История: Ранее в модели AdBanner были поля views, clicks (кэшированные счётчики).
Проблема:
- Рассинхронизация с реальными данными в
BannerAnalytics - Сложность поддержания консистентности
- N+1 проблемы при обновлении счётчиков
Решение: Удалить кэшированные счётчики, считать всё из BannerAnalytics в реальном времени.
Последствия:
- ✅ Один источник правды
- ✅ Нет рассинхронизации
- ⚠️ Производительность: больше JOIN и GROUP BY запросов
- Оптимизация: агрегация на уровне БД (COUNT DISTINCT), не N+1
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| BannerService | backend/src/domains/banners/services/banner.service.ts | Основная логика: получение баннеров, запись событий |
| BannerDeduplicationService | backend/src/domains/banners/services/banner-deduplication.service.ts | Дедупликация VIEW/CLICK с проверкой интервалов |
| BannerAnalyticsService | backend/src/domains/banners/services/banner-analytics.service.ts | Аналитика: Total/Unique метрики, графики, CTR |
| BannerController (User) | backend/src/domains/banners/controllers/user-banner.controller.ts | User API: /banners/home, /banners/:id/:action |
| BannerSlider | frontend/src/components/ui/BannerSlider.tsx | Frontend слайдер с Embla Carousel |
| Routes (User) | backend/src/domains/banners/routes/user-banners.routes.ts | API маршруты |
| Schemas | backend/src/domains/banners/schemas/ | Zod валидация, Fastify JSON Schema |
Data Flow
VIEW Tracking Flow:
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| AdBanner | Рекламный баннер | id, title, advertiser, imageUrl, linkUrl, isActive, displaySeconds |
| BannerAnalytics | События VIEW/CLICK | id, bannerId, userId, action, createdAt |
Relationships
Prisma Schema
AdBanner model (lines 1946-1962)
model AdBanner {
id String @id @default(uuid())
title String?
advertiser String?
imageUrl String
link String
startDate DateTime?
endDate DateTime?
displaySeconds Int? @default(15)
isActive Boolean @default(true)
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
analytics BannerAnalytics[]
@@map("ad_banners")
}
BannerAnalytics model (lines 1964-1982)
model BannerAnalytics {
id String @id @default(uuid())
bannerId String @map("banner_id")
userId String? @map("user_id")
action BannerAction
createdAt DateTime @default(now()) @map("created_at")
banner AdBanner @relation(fields: [bannerId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([bannerId, action, createdAt])
@@index([userId, action, createdAt])
@@map("banner_analytics")
}
enum BannerAction {
VIEW
CLICK
}
Важно: Индексы на (bannerId, action, createdAt) и (userId, action, createdAt) критичны для производительности дедупликации.
6. API Endpoints
- User API
- Admin API
| Метод | Эндпоинт | Описание | Auth |
|---|---|---|---|
| GET | /api/banners/home | Получить активные баннеры для слайдера (макс. 5) | Optional |
| POST | /api/banners/:id/view | Записать VIEW события (с дедупликацией 15 мин) | Required |
| POST | /api/banners/:id/click | Записать CLICK события (с дедупликацией 1 час) | Required |
GET /api/banners/home
Response:
{
"success": true,
"data": [
{
"id": "banner-uuid",
"title": "Название баннера",
"imageUrl": "https://...",
"linkUrl": "https://...",
"isActive": true,
"displaySeconds": 20
}
]
}
Логика:
- Возвращает только активные баннеры (
isActive = true) - Фильтрует по
startDate/endDate(если указаны) - Сортирует по
createdAt DESC - Лимит: 5 баннеров
POST /api/banners/:id/view
Request:
userIdиз JWT токена (автоматически)
Response (успех):
{
"success": true,
"recorded": true,
"message": "Просмотр успешно записан"
}
Response (дубликат):
{
"success": true,
"recorded": false,
"reason": "DUPLICATE_VIEW_WITHIN_15MIN",
"message": "Просмотр уже записан 5 минут назад",
"debug": {
"lastEventAt": "2026-01-29T10:30:00.000Z",
"deduplicationApplied": true
}
}
POST /api/banners/:id/click
Request:
userIdиз JWT токена (автоматически)
Response: Аналогично /view, но reason: "DUPLICATE_CLICK_WITHIN_1HOUR"
| Метод | Эндпоинт | Описание | Auth |
|---|---|---|---|
| GET | /admin/banners | Список баннеров с пагинацией и фильтрами | Admin |
| GET | /admin/banners/stats | Статистика по баннерам (Total/Unique метрики) | Admin |
| GET | /admin/banners/filters | Доступные фильтры (advertisers) | Admin |
| POST | /admin/banners | Создать баннер | Admin |
| PATCH | /admin/banners/:id | Обновить баннер | Admin |
| DELETE | /admin/banners/:id | Удалить баннер | Admin |
GET /admin/banners/stats
Query params:
period— период:7,30,all(дни) или24h,48h(часы)interval— группировка:hours,days,weeksbannerId— фильтр по конкретному баннеруadvertiser— фильтр по рекламодателюcustomStartDate/customEndDate— кастомный диапазон
Response:
{
"banners": [
{
"id": "banner-uuid",
"title": "Banner Title",
"metrics": {
"totalImpressions": 1000,
"totalClicks": 50,
"uniqueViews": 200,
"uniqueClicks": 10,
"realCTR": 5.0,
"totalCTR": 5.0,
"frequency": 5.0
}
}
],
"summary": {
"totalImpressions": 1000,
"totalClicks": 50,
"uniqueViews": 200,
"uniqueClicks": 10,
"realCTR": 5.0,
"totalCTR": 5.0,
"frequency": 5.0
},
"chartData": {
"total": [
{ "date": "2026-01-29", "views": 100, "clicks": 5, "formattedDate": "29 янв" }
],
"unique": [
{ "date": "2026-01-29", "views": 20, "clicks": 1, "formattedDate": "29 янв" }
]
}
}
7. Frontend Integration
BannerSlider Component
Location: frontend/src/components/ui/BannerSlider.tsx
Features:
- Embla Carousel для smooth swipe
- Auto-rotation с per-banner
displaySeconds - Dots indicator (вне баннера)
- VIEW tracking при активации слайда
- CLICK tracking при переходе по ссылке
- LocalStorage для скрытия на 30 минут
Props:
interface BannerSliderProps {
banners: BannerData[];
className?: string;
}
Usage:
import BannerSlider from '@/components/ui/BannerSlider';
<BannerSlider
banners={banners}
className="mb-4"
/>
API Client
Location: frontend/src/services/banner.api.ts
export async function trackBannerView(bannerId: string): Promise<void> {
await api.post(`/banners/${bannerId}/view`);
}
export async function trackBannerClick(bannerId: string): Promise<void> {
await api.post(`/banners/${bannerId}/click`);
}
8. Related
- Push Notifications — используют аналогичную систему аналитики
- UTM Tracking — атрибуция источников трафика для баннеров
- Feed Events — баннер клики могут попадать в историю активности
- Admin Analytics — общая архитектура аналитики