Skip to main content

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

VIEW засчитывается когда баннер становится видимым в слайдере (активный слайд).

Дедупликация: 15 минут на пользователя.

Механика:

  1. Слайд становится активным (currentIndex изменяется)
  2. Frontend ждёт 16ms (debounce для плавности)
  3. Проверка: VIEW для этого баннера ещё не был записан в текущей сессии (клиентский Set)
  4. Отправка POST /banners/:id/view
  5. Backend проверяет: был ли VIEW от этого пользователя за последние 15 минут
  6. При успехе: создаётся запись в BannerAnalytics с action = VIEW

Результат:

  • Один VIEW на баннер за просмотр слайдера (клиентская защита)
  • Повторный VIEW возможен через 15 минут (серверная защита)

CLICK Tracking

Когда засчитывается CLICK

CLICK засчитывается когда пользователь переходит по ссылке баннера (открытие в новом окне).

Дедупликация: 1 час на пользователя.

Механика:

  1. Пользователь кликает по баннеру
  2. window.open(linkUrl, '_blank') открывает ссылку
  3. Frontend детектирует переход через:
    • window.blur event (основной метод)
    • Fallback: проверка document.hidden через 3 секунды
  4. Отправка POST /banners/:id/click
  5. Backend проверяет: был ли CLICK от этого пользователя за последний час
  6. При успехе: создаётся запись в BannerAnalytics с action = CLICK

Результат:

  • Один CLICK на баннер в час (защита от случайных повторных кликов)

Metrics System

Total Impressions / Total Clicks

Подсчитывают все события из таблицы BannerAnalytics.

SELECT COUNT(*)
FROM banner_analytics
WHERE banner_id = ? AND action = 'VIEW'

Когда использовать:

  • Общая активность кампании
  • Оценка frequency (сколько раз показали одному пользователю)

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 errorFallback: возвращает recorded: falseDATABASE_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

КомпонентПутьОписание
BannerServicebackend/src/domains/banners/services/banner.service.tsОсновная логика: получение баннеров, запись событий
BannerDeduplicationServicebackend/src/domains/banners/services/banner-deduplication.service.tsДедупликация VIEW/CLICK с проверкой интервалов
BannerAnalyticsServicebackend/src/domains/banners/services/banner-analytics.service.tsАналитика: Total/Unique метрики, графики, CTR
BannerAdminServicebackend/src/domains/banners/services/banner-admin.service.tsAdmin CRUD: создание, обновление, удаление, toggle
BannerControllerbackend/src/domains/banners/controllers/user-banner.controller.tsUser API: /banners/home, /banners/:id/:action
AdminBannerControllerbackend/src/domains/banners/controllers/admin-banner.controller.tsAdmin API: CRUD, stats, filters, toggle
BannerSliderfrontend/src/components/ui/BannerSlider.tsxFrontend слайдер с Embla Carousel
Routes (User)backend/src/domains/banners/routes/user-banners.routes.tsUser API маршруты
Routes (Admin)backend/src/domains/banners/routes/admin-banners.routes.tsAdmin API маршруты
Schemasbackend/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/CLICKid, 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

МетодЭндпоинтОписание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"


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`);
}

  • Push Notifications -- используют аналогичную систему аналитики
  • UTM Tracking -- атрибуция источников трафика для баннеров
  • Feed Events -- баннер клики могут попадать в историю активности
  • Admin Analytics -- общая архитектура аналитики