Social Share
Описание
Модуль social-share в apps/backend отвечает за репосты платформы в соцсетях. Админ заводит посты, пользователь публикует их у себя, а факт публикации подтверждается переходом по реферальной ссылке из поста.
Поддерживаются восемь соцсетей: twitter, facebook, telegram, whatsapp, vk, ok, pinterest, linkedin.
Полное ТЗ — tz/active/social-share.
Логика работы
админ создаёт пост
↓
GET /social-share/posts?network=whatsapp → пост + готовый shareUrl
↓
пользователь жмёт кнопку → POST /posts/:id/click (счётчик) → уходит по shareUrl
↓
соцсеть открывает диалог публикации с текстом и реферальной ссылкой
↓
кто-то переходит по реферальной ссылке
↓
лендинг видит sp/sn/st в query → POST /social-share/track
↓
backend: подпись → бот? → автор? → кулдаун? → зачёт
1. Ссылка, которая уходит в пост
Backend строит реферальную ссылку по тем же правилам, что и фронтовый хук useReferralLink:
export const resolveReferralCode = (user: ReferralLinkOwner): ReferralCode | null => {
if (!user.hashedUid) {
return null;
}
if (user.useNotHashedLink && user.link) {
return { param: 'link', value: user.link };
}
return { param: 'refUid', value: user.hashedUid };
};
Дальше к ней дописываются параметры трекинга — после реферального кода:
https://magnet.xyz/?refUid=AB12CD3&sp=7&sn=whatsapp&st=9f2a1c4b8e0d
| Параметр | Значение |
|---|---|
sp | id поста |
sn | соцсеть |
st | подпись HMAC (12 hex) |
Лендинг разбирает код регулярками, требующими ровно 8 hex-символов для link и 7 base62 для refUid, а getParentByLink в user.service.ts режет код по - и трактует хвост как индексы матриц P2/P3.
Поэтому: дефис внутри кода недопустим, метаданные идут отдельными &-параметрами.
2. Диплинк соцсети
Шаблоны собраны в одном месте — social-share.constants.ts. Пустые опциональные параметры выбрасываются, значения кодируют ся encodeURIComponent:
[SocialNetwork.Whatsapp]: ({ text, pageUrl }) =>
`https://wa.me/?${buildQuery({ text: `${text}\n${pageUrl}` })}`,
Facebook и LinkedIn не принимают текст — их диплинки берут только url и тянут за головок с картинкой из OG-разметки страницы. Реферальная ссылка при этом передаётся, поэтому трекинг работает.
3. Подпись 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.
4. Зачёт публикации
Единица учёта — связка (автор, пост, соцсеть). Одна строка в social_share, уникальный индекс (user_id, post_id, network).
Зачёт происходит не по клику, а по живому переходу по ссылке. Правило: одна и та же связка засчитывается не чаще раза в cooldown_hours (по умолчанию 48).
return this.dataSource.transaction(async (manager) => {
const locked = await this.lockShare(manager, authorId, post.id, network);
const cooldownEndsAt = resolveNextAvailableAt(locked.lastCreditedAt, post.cooldownHours);
const skipReason = this.resolveSkipReason(isSelfVisit, Boolean(cooldownEndsAt));
const credited = !skipReason;
// ...
});
lockShare делает SELECT, при отсутствии строки — INSERT ... IGNORE, затем SELECT ... FOR UPDATE. Блокировка строки не даёт двум одновременным переходам пройти проверку кулдауна дважды.
5. Почему переход может не засчитаться
skipReason | Пишется в БД | Когда |
|---|---|---|
INVALID_SIGNATURE | нет | параметры ссылки подменены |
BOT | нет | краулер соцсети тянет превью, либо пустой UA |
REFERRER_NOT_FOUND | нет | код не резолвится в пользователя |
POST_INACTIVE | нет | пост выключен или мягко удалён |
NETWORK_NOT_ALLOWED | нет | пост больше не публикуется в эту соцсеть |
SELF_VISIT | да | по ссылке перешёл сам автор |
COOLDOWN | да | кулдаун ещё не истёк |
Первые пять отсекаются до транзакции: краулеры соцсетей дёргают ссыл ку сразу после публикации, и без этого фильтра пост засчитывался бы сам собой.
Схема данных
| Таблица | Роль |
|---|---|
social_share_post | посты админа; networks и hashtags — JSON, soft delete |
social_share | агрегат по связке; last_credited_at — источник истины кулдауна |
social_share_visit | append-only лог переходов: credited, skip_reason, ip, user_agent |
Миграция: src/migrations/1782769264315-create-social-share.ts, зарегистрирована в BACKEND_MIGRATIONS (app.module.ts).
API
Пользовательские — JwtAuthGuard
| Метод | Путь | Ответ |
|---|---|---|
GET | /social-share/networks | [{ network, supportsText }] |
GET | /social-share/posts?network= | { referralAvailable, referralUrl, posts[] } |
POST | /social-share/posts/:postId/click | { shareUrl, trackedReferralUrl, status, nextAvailableAt } |
GET | /social-share/status?network= | { totalClicks, totalVisits, totalCredits, totalRewardPoints, byNetwork[], items[] } |
Публичный — без guard'ов
| Метод | Путь | Тело | Ответ |
|---|---|---|---|
POST | /social-share/track | { link | refUid, sp, sn, st } | { credited, skipReason, status, nextAvailableAt } |
Админские — JwtAuthGuard + RolesGuard
Роли: AdminSocialShare, SocialShareRead, SocialShareWrite.
POST|GET /admin/posts, GET|PATCH|DELETE /admin/posts/:id, GET /admin/shares, GET /admin/stats.
Переменные окружения
| Переменная | Обязательна | Назначение |
|---|---|---|
NEXT_PUBLIC_BASE_URL | да | база реферальных ссылок |
SOCIAL_SHARE_SECRET | нет | секрет подписи st; если пуст — используется JWT_SECRET |
Известные ограничения
- Факт публикации подтверждается только переходом по ссылке — API соцсетей мы не используем. Автор может засчитать себе публикацию, открыв ссылку из другого браузера. Кулдаун ограничивает потолок такой накрутки.
SELF_VISITловит автора, только если он залогинен в том же браузере (проверяетсяAuthorization-заголовок).- Rate limiting отсутствует: в backend нет
@nestjs/throttler. - Вознаграждения не начисляются,
reward_pointsтолько хранится.