Перейти к основному содержимому

ТЗ: Репосты в соцсетях (social-share) для backend

Метаданные

ПараметрЗначение
Дата создания2026-07-10
Дата последнего изменения2026-07-10
Статус апрува⏳ На рассмотрении
Дата апрува

1. Назначение

Дать пользователям простой способ рекламировать платформу в соцсетях и учитывать этот вклад.

Админ заводит в БД набор постов. Пользователь выбирает соцсеть, видит список доступных постов и одним нажатием уходит в нативный диалог публикации нужной соцсети — с уже подставленным текстом и своей реферальной ссылкой. Когда кто-то переходит по этой ссылке, публикация засчитывается.

Вознаграждение за публикации в этой итерации не начисляется: мы только фиксируем факт зачёта и храним rewardPoints на будущее. Реализуется только backend; фронт — отдельной задачей (см. раздел 3.4).

Реализуется в apps/backend (NestJS + MySQL/TypeORM) новым модулем social-share.


2. Функциональные требования

2.1. Пользовательские сценарии

  1. Просмотр постов. Пользователь открывает раздел, выбирает соцсеть (twitter, facebook, telegram, whatsapp, vk, ok, pinterest, linkedin) и видит список активных постов с текстом, картинкой и статусом («можно выложить», «уже засчитан, следующая попытка через N часов»).
  2. Публикация. Пользователь нажимает кнопку, фронт открывает shareUrl — диплинк соцсети с предзаполненным постом. В тексте поста стоит реферальная ссылка пользователя с параметрами трекинга.
  3. Зачёт. Кто-то из друзей переходит по этой ссылке. Лендинг вызывает публичный эндпоинт трекинга, backend засчитывает публикацию автору ссылки.
  4. Статус. Пользователь видит свою сводку: сколько кликов, сколько живых переходов, сколько публикаций засчитано, какие посты на кулдауне.
  5. Админ. Админ создаёт, редактирует, выключает и мягко удаляет посты; смотрит статистику по постам и пользователям.

2.2. Бизнес-логика

Ключ учёта

Единица учёта — связка (автор, пост, соцсеть). Именно на неё навешен кулдаун.

Правило кулдауна

Одна и та же связка засчитывается не чаще одного раза в cooldownHours (по умолчанию 48 часов).

Отсюда следуют требуемые сценарии:

ДействиеРезультат
Пост A в Facebook + пост B в WhatsAppОба засчитываются — разные связки
Пост A в Facebook + пост A в WhatsAppОба засчитываются — разные соцсети
Пост A в WhatsApp дважды подрядЗасчитывается только первый
Пост A в WhatsApp повторно через 48 часовЗасчитывается снова

Что засчитывает публикацию

Не нажатие кнопки, а живой переход по реферальной ссылке из поста. Иначе пользователь мог бы «выкладывать» посты, не выкладывая их.

Переход не засчитывается, если:

Причина (skipReason)Когда
INVALID_SIGNATUREПодпись st не сходится — параметры ссылки подменены
BOTUser-Agent краулера соцсети (превью ссылки) или пустой
REFERRER_NOT_FOUNDРеферальный код не резолвится в пользователя
POST_INACTIVEПост выключен или мягко удалён
NETWORK_NOT_ALLOWEDПост больше не публикуется в эту соцсеть
SELF_VISITПо ссылке перешёл сам автор, залогиненный в том же браузере
COOLDOWNКулдаун по связке ещё не истёк

Переходы с причинами INVALID_SIGNATURE и BOT не пишутся в БД вовсе — иначе краулеры соцсетей раздували бы таблицу и создавали вектор флуда. Остальные пишутся в лог визитов с проставленной причиной: по ним видно, почему пост не был засчитан.

Статусы поста для пользователя

СтатусЗначение
NEWДиплинк ни разу не открывали
AWAITING_VISITДиплинк открывали, но засчитанных переходов ещё не было
COUNTEDПубликация засчитана, идёт кулдаун
AVAILABLEКулдаун истёк, пост можно выложить снова

2.3. UI/UX требования

Табы соцсетей → список карточек постов. Кнопка «Поделиться» открывает shareUrl в новой вкладке. Карточка на кулдауне показывает nextAvailableAt. Если у пользователя ещё нет реферальной ссылки (referralAvailable: false), раздел показывает заглушку.


3. Техническая реализация

3.1. Архитектура

apps/backend/src/social-share/
├── models/
│ ├── social-share-post.entity.ts # social_share_post — посты админа
│ ├── social-share.entity.ts # social_share — агрегат (автор, пост, соцсеть)
│ └── social-share-visit.entity.ts # social_share_visit — append-only лог переходов
├── dto/
├── utils/social-share-link.util.ts # чистые функции: ссылки, подпись, боты, кулдаун
├── social-share.constants.ts # шаблоны диплинков, обязательные поля, bot-regex
├── social-share.service.ts
├── social-share.controller.ts # JwtAuthGuard
├── social-share-public.controller.ts # без guard'ов
├── social-share.admin.controller.ts # JwtAuthGuard + RolesGuard
└── social-share.module.ts

Плюс миграция apps/backend/src/migrations/1782769264315-create-social-share.ts, зарегистрированная в BACKEND_MIGRATIONS (app.module.ts), и общие константы/роли в packages/reusable-magnet-common.

Схема данных

social_share_post — контент поста: networks (JSON-массив соцсетей), name, title, text, description, image_url, hashtags (JSON), is_active, sort_order, reward_points, cooldown_hours, soft delete.

social_share — ровно одна строка на связку, уникальный индекс (user_id, post_id, network). Хранит счётчики clicks_count / visits_count / credits_count и метки last_clicked_at / last_visited_at / last_credited_at. Последняя и есть источник истины для кулдауна.

social_share_visit — по строке на каждый живой переход: credited, skip_reason, ip, user_agent, referer.

3.2. Описание технической реализации

Реферальная ссылка

Backend повторяет формат фронтового хука useReferralLink байт в байт:

если нет hashedUid            → реферальной ссылки нет вообще
если useNotHashedLink && link → ${BASE}/?link=${user.link} (link — 8 hex-символов)
иначе → ${BASE}/?refUid=${user.hashedUid} (hashedUid — 7 base62-символов)

BASE берётся из NEXT_PUBLIC_BASE_URL (завершающие слэши срезаются).

К ней дописываются параметры трекинга — после реферального кода, не меняя его:

https://magnet.xyz/?refUid=AB12CD3&sp=7&sn=whatsapp&st=9f2a1c4b8e0d
│ │ │ └─ подпись
│ │ └─ соцсеть
│ └─ id поста
└─ реферальный код (не трогаем)

Это критично: лендинг разбирает код регулярками [?&]link=([a-fA-F0-9]{8})(?:&|$) и [?&]refUid=([A-Za-z0-9]{7})(?:&|$), а getParentByLink в user.service.ts режет код по - и трактует хвост как индексы матриц P2/P3. Поэтому дефис внутри кода недопустим, а лишние &-параметры безопасны.

Подпись st

key = HMAC-SHA256(SOCIAL_SHARE_SECRET ?? JWT_SECRET, 'magnet:social-share:v1')
st = HMAC-SHA256(key, `${referralCode}:${postId}:${network}`).hex.slice(0, 12)

Ключ выводится из секрета, а не равен ему, чтобы компрометация share-подписи ничего не давала для подделки JWT. Сверка — timingSafeEqual. Подпись не даёт подменить sp/sn в чужой или своей ссылке.

Атомарность зачёта

Зачёт идёт внутри dataSource.transaction:

  1. SELECT строки связки; если её нет — INSERT ... IGNORE (гасит гонку двух параллельных переходов);
  2. SELECT ... FOR UPDATE той же строки (lock: { mode: 'pessimistic_write' });
  3. проверка now - last_credited_at >= cooldown_hours (в миллисекундах);
  4. INSERT строки визита + UPDATE счётчиков и last_credited_at.

Блокировка строки не даёт двум одновременным переходам по одной ссылке пройти проверку кулдауна дважды.

3.3. API

Пользовательские (JwtAuthGuard)

МетодПутьНазначение
GET/social-share/networksСписок соцсетей
GET/social-share/posts?network=Посты с готовыми диплинками и статусами
POST/social-share/posts/:postId/clickЗафиксировать клик, получить диплинк
GET/social-share/status?network=Сводка и построчный статус публикаций

Публичный (без guard'ов)

МетодПутьНазначение
POST/social-share/trackЗарегистрировать переход, при возможности засчитать

Админские (JwtAuthGuard + RolesGuard)

Роли: AdminSocialShare, SocialShareRead (чтение), SocialShareWrite (запись). Admin и UltimateAdmin проходят всегда.

МетодПуть
POST/social-share/admin/posts
GET/social-share/admin/posts
GET/social-share/admin/posts/:id
PATCH/social-share/admin/posts/:id
DELETE/social-share/admin/posts/:id
GET/social-share/admin/shares
GET/social-share/admin/stats

3.4. Интеграция с фронтом

  1. Раздел «Поделиться»: GET /social-share/networks → табы; GET /social-share/posts?network=X → карточки. shareUrl уже готов — вешаем на <a href target="_blank" rel="noopener">, чтобы не ловить блокировщик попапов.
  2. Параллельно с переходом отправляем POST /social-share/posts/:postId/click (счётчик, ответ не блокирует навигацию).
  3. Экран статуса: GET /social-share/status.
  4. Лендинг: если в location.search есть sp, sn, st — один раз за загрузку шлём POST /social-share/track с телом { link | refUid, sp, sn, st }. Существующая логика разбора ?link= / ?refUid= не меняется.

3.5. Переменные окружения

ПеременнаяОбязательнаНазначение
NEXT_PUBLIC_BASE_URLдаБаза реферальных ссылок
SOCIAL_SHARE_SECRETнетСекрет подписи st; если пуст — используется JWT_SECRET

4. Проблемы и компромиссы

4.1. Известные ограничения

  1. Зачёт нельзя доказать криптографически

    • Мы не имеем API соцсетей и не видим сам пост. Единственный сигнал — переход по ссылке.
    • Автор может открыть свою ссылку из другого браузера/устройства и засчитать себе публикацию. Защита SELF_VISIT ловит только случай, когда он залогинен в том же браузере.
    • Кулдаун в 48 часов ограничивает потолок такой накрутки одной публикацией на связку в двое суток.
  2. Facebook и LinkedIn не принимают текст

    • Их диплинки берут только url; заголовок и картинку они тянут из OG-разметки страницы.
    • Реферальная ссылка при этом передаётся, поэтому трекинг работает. Поле text для этих соцсетей игнорируется — это ограничение платформ, не наше.
  3. Определение ботов по User-Agent

    • Regex ловит краулеры соцсетей, подтягивающие превью. Возможны ложные срабатывания на экзотических in-app браузерах.
  4. Нет rate limiting

    • В backend нет @nestjs/throttler. Публичный /track пишет строку визита на каждый валидный запрос от не-бота.

4.2. Технический долг

  • Начисление вознаграждений: reward_points пока только хранится. Подключить ClaimTokensService.createAvailableTokens (как в l2e.service.ts) и добавить Product.SocialShare.
  • Rate limiting на POST /social-share/track (по IP, Redis).
  • Ретеншн social_share_visit: таблица растёт линейно, нужен cron-прунер старше N месяцев.
  • Локализация постов: сейчас пост одноязычный. При необходимости — таблица переводов по образцу l2e_translations.
  • Админ-панель на фронте для CRUD постов.

4.3. Риски

РискМитигация
Накрутка переходов ботами с браузерным UAКулдаун 48 ч; вознаграждения ещё не начисляются; лог визитов с IP/UA
Флуд публичного /trackБоты и невалидные подписи отсекаются до записи в БД; TODO throttler
Соцсеть поменяла формат диплинкаШаблоны вынесены в social-share.constants.ts, правка в одном месте
Изменение формата реферальной ссылки на фронтеresolveReferralCode покрыт тестами на паритет с useReferralLink

5. Вопросы на дополнительное обсуждение

  • Вопрос 1: Сколько баллов давать за публикацию и в какой валюте начислять вознаграждение?

    • Кому адресовано: продукт
  • Вопрос 2: Нужен ли зачёт за уникальные переходы (разные визитёры), а не за первый любой переход в окне?

    • Кому адресовано: продукт / антифрод
  • Вопрос 3: Нужна ли локализация текстов постов на старте?

    • Кому адресовано: продукт