GET /api/internal/user-activity/sessions/{anonymousId}/link — Связка AnonymousId → UserId (merge favorites)
Internal m2m точка проверки связи «устройство ↔ пользователь». Единственный потребитель — favorites:
при входе гостя фронт триггерит merge избранного (README favorites), и favorites должен определить
владельца гостевых записей (Favorite.AnonymousId = Session.AnonymousId, связь
session.md «Session ← Favorite») и стратегию слияния. Endpoint не мутирует
состояние, отдаёт проекцию связывания сессий устройства (инвариант связывания — INV-SES-002 LinkOnce,
session.md).
Назначение
По AnonymousId устройства вернуть, связано ли оно с пользователем, кто владелец (UserId последней
связанной сессии) и признак «общего устройства» (сколько разных пользователей входило с него) — сигнал для
выбора merge-стратегии на стороне favorites.
| Поле | Значение |
|---|
| Сервис/контекст | user-activity |
| API area | internal (m2m) |
| Feature group | sessions |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/internal/get-session-link.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | System (ServiceAccountActor, m2m internal — сервис favorites) |
| Auth policy / Permissions | AuthorizationPolicies.InternalService ("internal-service"); internal scope user-activity.sessions.read-link |
| Scope (RBAC) | не применяется — контекст вне tenant (ADR-0052 п.2) |
| Record-level | не применяется — доверенный m2m-вызов; AnonymousId — ключ запроса, авторизацию merge выполняет favorites (владелец гостевых записей) |
| Tenant isolation | вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются, TenantId в запросе не участвует |
| Audit | read (не sensitive); PII (Device.IpAddress/UserAgent) в ответ не входит |
Заголовок X-Anonymous-Id (self-scope гостя публичных ручек) здесь не используется: вызов m2m, а
AnonymousId приходит сегментом маршрута от доверенного internal-клиента.
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/internal/user-activity/sessions/{anonymousId}/link |
| Success status | 200 (всегда для валидного Guid; отсутствие связи — IsLinked = false, не 404) |
| Idempotency header | не применяется (GET безопасен и идемпотентен по природе) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
AnonymousId | route | Guid | Да | != Guid.Empty; формат Guid | идентификатор устройства (не PII) |
| service identity | m2m token | — | Да | валидный service-account токен, scope user-activity.sessions.read-link (pipeline, слой 1–2) | — |
Модель ответа SessionLinkResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
AnonymousId | Guid | Да | echo query.AnonymousId | — | Запрошенное устройство. |
IsLinked | bool | Да | computed (шаг 4) | — | Есть ли хотя бы одна сессия устройства, связанная с пользователем. |
UserId | Guid? | Нет | Session.UserId последней связанной сессии | — | Владелец гостевых записей (null — устройство никогда не входило под пользователем). |
LinkedAt | DateTimeOffset? | Нет | Session.LinkedAt той же сессии | — | Момент последнего связывания устройства (серверное время). |
DistinctUserCount | int | Да | computed (шаг 4) | — | Сколько разных UserId входило с устройства: 0 — чистый гость; 1 — однозначный merge к UserId; > 1 — общее устройство, favorites выбирает стратегию. |
Идентификаторы сессий и Device-поля наружу не возвращаются (внутренние данные контекста,
session.md).
Алгоритм
- Контекст и доступ. Вызывает сервис
favorites (доверенный m2m internal); требуется scope
user-activity.sessions.read-link. Запрос вне tenant (ADR-0052 п.2); авторизацию merge выполняет сам
favorites (владелец гостевых записей), поэтому record-level self-scope здесь не применяется.
- Проверка входа.
AnonymousId != Guid.Empty — иначе VALIDATION_FAILED (400). Отсутствие сессий по
AnonymousId — не ошибка и не 404, а валидный ответ IsLinked = false (устройство ещё не трекалось /
только гостевые сессии). Статус сессий на связку не влияет: важно лишь, связывалось ли устройство
когда-либо, независимо от того, Active сессия или уже Inactive.
- Что читаем. Все сессии устройства (
AnonymousId), у которых заполнен UserId (связанные с
пользователем), отсортированные по LinkedAt (новые первыми). Набор ограничен (одно устройство — единицы-
десятки сессий за retention 12 мес). Из записей нужны только UserId и LinkedAt; Device-поля и
остальной агрегат не читаются.
- Расчёт и маппинг. Переходов нет (чтение):
AnonymousId = запрошенное устройство;
IsLinked = есть хотя бы одна связанная сессия;
UserId = UserId последней по LinkedAt связанной сессии (актуальный владелец при переключении
пользователя, INV-SES-002);
LinkedAt = LinkedAt той же сессии;
DistinctUserCount = число различных UserId, входивших с устройства.
- Результат.
200 с проекцией связывания. Для валидного Guid ответ всегда 200 (в т.ч.
IsLinked = false).
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none (read-only) |
| External calls | none (только чтение user_activity.sessions) |
| Cache / projections / search | none (короткий btree-lookup; кеш не вводится — связка меняется при входе) |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | AnonymousId = Guid.Empty / неверный формат в маршруте | «Некорректный идентификатор устройства» | no retry |
unauthenticated | 401 | нет/невалидный service-account токен | «Требуется аутентификация сервиса» | no retry |
forbidden | 403 | actor без scope user-activity.sessions.read-link (не доверенный internal-клиент) | «Недостаточно прав» | no retry |
| — (не ошибка) | 200 | нет связанных сессий по AnonymousId | тело IsLinked = false, UserId = null | — |
404 для «связка не найдена» не возвращается: отсутствие владельца — валидный результат merge-логики
(favorites трактует IsLinked = false как чистого гостя).
Acceptance-критерии
- Given устройство входило под ровно одним пользователем, When
GET .../{anonymousId}/link, Then
200, IsLinked = true, UserId заполнен, DistinctUserCount = 1 (однозначный merge к UserId).
- Given устройство никогда не входило под пользователем (только гостевые сессии или трекинга не было),
When
GET, Then 200, IsLinked = false, UserId = null, DistinctUserCount = 0 (не 404) —
favorites трактует как чистого гостя.
- Given с устройства входило более одного пользователя, When
GET, Then DistinctUserCount > 1,
UserId — владелец последней по LinkedAt связанной сессии (INV-SES-002); это сигнал «общего устройства»
для выбора merge-стратегии favorites.
- Given есть и
Active, и Inactive связанные сессии, When GET, Then статус сессии на связку
не влияет — учитывается факт когда-либо выполненного связывания.
- Given
AnonymousId = Guid.Empty/неверный формат, When GET, Then VALIDATION_FAILED (400).
- Given вызывающий без scope
user-activity.sessions.read-link, When GET, Then 403; без
service-токена — 401.
Совместимость и наблюдаемость
- Новые optional-поля ответа (напр.
LastLinkedSessionId) — additive, без breaking change;
удаление/переименование/смена типа полей — breaking (версия internal-клиента + rollout).
- Logs:
session-link resolved (AnonymousId, IsLinked, DistinctUserCount; UserId — без PII, id);
metrics: user_activity_session_link_lookup_total{result}, latency p95 (< 1 с, README SLO
internal-списков); traces — сквозной trace через traceparent; dashboard Grafana pin-user-activity; runbook —
docs/06-services/user-activity/runbooks/ (будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Сущность: domain/session.md (поля/инварианты
Session, связь
Session ← Favorite, INV-SES-002 LinkOnce); карта API — README.md.
- Смежные:
../../favorites/README.md (контракт X-Anonymous-Id, триггер и стратегия merge);
событие связывания при входе — ../events/session-linked.md (будущий путь по конвенции).
- Решения: ADR-0052 (вне tenant), ADR-0054 (модуль
sessions), ADR-0056 (simplicity-first, PostgreSQL-only).