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
BannerController (User)backend/src/domains/banners/controllers/user-banner.controller.tsUser API: /banners/home, /banners/:id/:action
BannerSliderfrontend/src/components/ui/BannerSlider.tsxFrontend слайдер с Embla Carousel
Routes (User)backend/src/domains/banners/routes/user-banners.routes.tsAPI маршруты
Schemasbackend/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/CLICKid, 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

МетодЭндпоинтОписание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<void> {
await api.post(`/banners/${bannerId}/view`);
}

export async function trackBannerClick(bannerId: string): Promise<void> {
await api.post(`/banners/${bannerId}/click`);
}

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