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 |
| BannerAdminService | backend/src/domains/banners/services/banner-admin.service.ts | Admin CRUD: создание, обновление, удаление, toggle |
| BannerController | backend/src/domains/banners/controllers/user-banner.controller.ts | User API: /banners/home, /banners/:id/:action |
| AdminBannerController | backend/src/domains/banners/controllers/admin-banner.controller.ts | Admin API: CRUD, stats, filters, toggle |
| BannerSlider | frontend/src/components/ui/BannerSlider.tsx | Frontend слайдер с Embla Carousel |
| Routes (User) | backend/src/domains/banners/routes/user-banners.routes.ts | User API маршруты |
| Routes (Admin) | backend/src/domains/banners/routes/admin-banners.routes.ts | Admin 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, link, isActive, displaySeconds |
| BannerAnalytics | События VIEW/CLICK | id, bannerId, userId, sessionId, action, userAgent, ipAddress, referrer, createdAt |
Relationships
Prisma Schema
AdBanner model
model AdBanner {
id String @id @default(cuid())
title String @db.VarChar(255)
notes String? @db.VarChar(1000)
imageUrl String @map("image_url") @db.VarChar(500)
link String @db.VarChar(500)
isActive Boolean @default(true) @map("is_active")
startDate DateTime? @map("start_date")
endDate DateTime? @map("end_date")
displaySeconds Int? @map("display_seconds")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
advertiser String? @db.VarChar(100)
analytics BannerAnalytics[]
@@map("ad_banners")
}
BannerAnalytics model
model BannerAnalytics {
id String @id @default(cuid())
bannerId String @map("banner_id")
userId String? @map("user_id")
sessionId String? @map("session_id")
action BannerAction
userAgent String? @map("user_agent") @db.VarChar(500)
ipAddress String? @map("ip_address") @db.VarChar(45)
referrer String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
banner AdBanner @relation(fields: [bannerId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
@@index([bannerId, userId, action], map: "banner_user_action_idx")
@@index([bannerId, sessionId, action], map: "banner_session_action_idx")
@@index([bannerId, ipAddress, userAgent, action], map: "banner_device_action_idx")
@@index([bannerId, createdAt], map: "banner_time_idx")
@@map("banner_analytics")
}
enum BannerAction {
VIEW
CLICK
}
Важно: Индексы оптимизированы для дедупликации по пользователю, сессии, устройству и для временных запросов аналитики.
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/:id | Получить баннер по ID | Admin |
| GET | /admin/banners/stats | Статистика по баннерам (Total/Unique метрики) | Admin |
| GET | /admin/banners/filters | Доступные фильтры (advertisers) | Admin |
| GET | /admin/banners/advertisers | Автокомплит рекламодателей | Admin |
| GET | /admin/banners/titles | Автокомплит названий баннеров | Admin |
| POST | /admin/banners | Создать баннер | Admin |
| PUT | /admin/banners/:id | Обновить баннер | Admin |
| POST | /admin/banners/:id/toggle | Переключить активность баннера | Admin |
| DELETE | /admin/banners/:id | Удалить баннер | Admin |
GET /admin/banners/stats
Query params:
period-- период:7,30,all(дни) или24h,48h(часы)interval-- группировка:hours,daysbannerTitle-- фильтр по названию баннера (подстрока, case-insensitive)advertiser-- фильтр по рекламодателю (подстрока, case-insensitive)customStartDate/customEndDate-- кастомный диапазонhourStart/hourEnd-- фильтр по часам внутри дня
Response (BannerStats):
{
"chartData": [
{ "date": "2026-01-29", "views": 100, "clicks": 5, "formattedDate": "29 янв" }
],
"summary": {
"totalViews": 1000,
"totalClicks": 50,
"ctr": 5.0,
"banners": [
{
"id": "banner-uuid",
"title": "Banner Title",
"views": 500,
"clicks": 25,
"ctr": 5.0
}
],
"campaigns": [
{
"name": "Advertiser Name",
"banners": 2,
"totalViews": 1000,
"totalClicks": 50,
"avgCtr": 5.0
}
],
"period": 30,
"interval": "days"
}
}
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<BannerActionResponse> {
return await apiClient.post<BannerActionResponse>(`/api/banners/${bannerId}/view`);
}
export async function trackBannerClick(bannerId: string): Promise<BannerActionResponse> {
return await apiClient.post<BannerActionResponse>(`/api/banners/${bannerId}/click`);
}
8. Related
- Push Notifications -- используют аналогичную систему аналитики
- UTM Tracking -- атрибуция источников трафика для баннеров
- Feed Events -- баннер клики могут попадать в историю активности
- Admin Analytics -- общая архитектура аналитики