ТЗ: Репосты в соцсетях (social-share) для backend
Метаданные
| Параметр | Значение |
|---|---|
| Дата создания | 2026-07-10 |
| Дата последнего изменения | 2026-07-10 |
| Статус апрува | ⏳ На рассмотрении |
| Дата апрува | — |
1. Назначение
Дать пользователям простой способ рекламировать платформу в соцсетях и учитывать этот вклад.
Админ заводит в БД набор постов. Пользователь выбирает соцсеть, видит список доступных постов и одним нажатием уходит в нативный диалог публикации нужной соцсети — с уже подставленным текстом и своей реферальной ссылкой. Когда кто-то переходит по этой ссылке, публикация засчитывается.
Вознаграждение за публикации в этой итерации не начисляется: мы только фиксируем факт зачёта и храним rewardPoints на будущее. Реализуется только backend; фронт — отдельной задачей (см. раздел 3.4).
Реализуется в apps/backend (NestJS + MySQL/TypeORM) новым модулем social-share.
2. Функциональные требования
2.1. Пользовательские сценарии
- Просмотр постов. Пользователь открывает раздел, выбирает соцсеть (
twitter,facebook,telegram,whatsapp,vk,ok,pinterest,linkedin) и видит список активных постов с текстом, картинкой и статусом («можно выложить», «уже засчитан, следующая попытка через N часов»). - Публикация. Пользователь нажимает кнопку, фронт открывает
shareUrl— диплинк соцсети с предзаполненным постом. В тексте поста стоит реферальная ссылка пользователя с параметрами трекинга. - Зачёт. Кто-то из друзей переходит по этой ссылке. Лендинг вызывает публичный эндпоинт трекинга, backend засчитывает публикацию автору ссылки.
- Статус. Пользователь видит свою сводку: сколько кликов, сколько живых переходов, сколько публикаций засчитано, какие посты на кулдауне.
- Админ. Админ создаёт, редактирует, выключает и мягко удаляет пос ты; смотрит статистику по постам и пользователям.
2.2. Бизнес-логика
Ключ учёта
Единица учёта — связка (автор, пост, соцсеть). Именно на неё навешен кулдаун.
Правило кулдауна
Одна и та же связка засчитывается не чаще одного раза в cooldownHours (по умолчанию 48 часов).
Отсюда следуют требуемые сценарии:
| Действие | Результат |
|---|---|
| Пост A в Facebook + пост B в WhatsApp | Оба засчитываются — разные связки |
| Пост A в Facebook + пост A в WhatsApp | Оба засчитываются — разные соцсети |
| Пост A в WhatsApp дважды подряд | Засчитывается только первый |
| Пост A в WhatsApp повторно через 48 часов | Засчитывается снова |
Что засчитывает публикацию
Не нажатие кнопки, а живой переход по реферальной ссылке из поста. Иначе пользователь мог бы «выкладывать» посты, не выкладывая их.
Переход не засчитывается, если:
Причина (skipReason) | Когда |
|---|---|
INVALID_SIGNATURE | Подпись st не сходится — параметры ссылки подменены |
BOT | User-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:
SELECTстроки связки; если её нет —INSERT ... IGNORE(гасит гонку двух параллельных переходов);SELECT ... FOR UPDATEтой же строки (lock: { mode: 'pessimistic_write' });- проверка
now - last_credited_at >= cooldown_hours(в миллисекундах); 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. Интеграция с фронтом
- Раздел «Поделиться»:
GET /social-share/networks→ табы;GET /social-share/posts?network=X→ карточки.shareUrlуже готов — вешаем на<a href target="_blank" rel="noopener">, чтобы не ловить блокировщик попапов. - Параллельно с переходом отправляем
POST /social-share/posts/:postId/click(счётчик, ответ не блокирует навигацию). - Э кран статуса:
GET /social-share/status. - Лендинг: если в
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. Известные ограничения
-
Зачёт нельзя доказать криптографически
- Мы не имеем API соцсетей и не видим сам пост. Единственный сигнал — переход по ссылке.
- Автор может открыть свою ссылку из другого браузера/устройства и засчитать себе публикацию. Защита
SELF_VISITловит только случай, когда он залогинен в том же браузере. - Кулдаун в 48 часов ограничивает потолок такой накрутки одной публикацией на связку в двое суток.
-
Facebook и LinkedIn не принимают текст
- Их диплинки берут только
url; заголовок и картинку они тянут из OG-разметки страницы. - Реферальная ссылка при этом передаётся, поэтому трекинг работает. Поле
textдля этих соцсетей игнорируется — это ограничение платформ, не наше.
- Их диплинки берут только
-
Определение ботов по User-Agent
- Regex ловит краулеры соцсетей, подтягивающие превью. Возможны ложные срабатывания на экзотических in-app браузерах.
-
Нет rate limiting
- В backend нет
@nestjs/throttler. Публичны й/trackпишет строку визита на каждый валидный запрос от не-бота.
- В backend нет
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: Нужна ли локализация текстов постов на старте?
- Кому адресовано: продукт