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

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.

Metadata

ПолеЗначение
Сервис/контекстuser-activity
API areainternal (m2m)
Feature groupsessions
Статусdraft
Документdocs/06-services/user-activity/api/internal/get-session-link.md

Актор и доступ

ПроверкаЗначение
Actor typeSystem (ServiceAccountActor, m2m internal — сервис favorites)
Auth policy / PermissionsAuthorizationPolicies.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 в запросе не участвует
Auditread (не sensitive); PII (Device.IpAddress/UserAgent) в ответ не входит

Заголовок X-Anonymous-Id (self-scope гостя публичных ручек) здесь не используется: вызов m2m, а AnonymousId приходит сегментом маршрута от доверенного internal-клиента.

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/internal/user-activity/sessions/{anonymousId}/link
Success status200 (всегда для валидного Guid; отсутствие связи — IsLinked = false, не 404)
Idempotency headerне применяется (GET безопасен и идемпотентен по природе)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
AnonymousIdrouteGuidДа!= Guid.Empty; формат Guidидентификатор устройства (не PII)
service identitym2m tokenДавалидный service-account токен, scope user-activity.sessions.read-link (pipeline, слой 1–2)

Модель ответа SessionLinkResult

ПолеТипОбязательностьИсточникMaskingОписание
AnonymousIdGuidДаecho query.AnonymousIdЗапрошенное устройство.
IsLinkedboolДаcomputed (шаг 4)Есть ли хотя бы одна сессия устройства, связанная с пользователем.
UserIdGuid?НетSession.UserId последней связанной сессииВладелец гостевых записей (null — устройство никогда не входило под пользователем).
LinkedAtDateTimeOffset?НетSession.LinkedAt той же сессииМомент последнего связывания устройства (серверное время).
DistinctUserCountintДаcomputed (шаг 4)Сколько разных UserId входило с устройства: 0 — чистый гость; 1 — однозначный merge к UserId; > 1 — общее устройство, favorites выбирает стратегию.

Идентификаторы сессий и Device-поля наружу не возвращаются (внутренние данные контекста, session.md).

Алгоритм

  1. Контекст и доступ. Вызывает сервис favorites (доверенный m2m internal); требуется scope user-activity.sessions.read-link. Запрос вне tenant (ADR-0052 п.2); авторизацию merge выполняет сам favorites (владелец гостевых записей), поэтому record-level self-scope здесь не применяется.
  2. Проверка входа. AnonymousId != Guid.Empty — иначе VALIDATION_FAILED (400). Отсутствие сессий по AnonymousId — не ошибка и не 404, а валидный ответ IsLinked = false (устройство ещё не трекалось / только гостевые сессии). Статус сессий на связку не влияет: важно лишь, связывалось ли устройство когда-либо, независимо от того, Active сессия или уже Inactive.
  3. Что читаем. Все сессии устройства (AnonymousId), у которых заполнен UserId (связанные с пользователем), отсортированные по LinkedAt (новые первыми). Набор ограничен (одно устройство — единицы- десятки сессий за retention 12 мес). Из записей нужны только UserId и LinkedAt; Device-поля и остальной агрегат не читаются.
  4. Расчёт и маппинг. Переходов нет (чтение):
    • AnonymousId = запрошенное устройство;
    • IsLinked = есть хотя бы одна связанная сессия;
    • UserId = UserId последней по LinkedAt связанной сессии (актуальный владелец при переключении пользователя, INV-SES-002);
    • LinkedAt = LinkedAt той же сессии;
    • DistinctUserCount = число различных UserId, входивших с устройства.
  5. Результат. 200 с проекцией связывания. Для валидного Guid ответ всегда 200 (в т.ч. IsLinked = false).

Побочные эффекты

ТипДетали
Integration eventsnone (read-only)
External callsnone (только чтение user_activity.sessions)
Cache / projections / searchnone (короткий btree-lookup; кеш не вводится — связка меняется при входе)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400AnonymousId = Guid.Empty / неверный формат в маршруте«Некорректный идентификатор устройства»no retry
unauthenticated401нет/невалидный service-account токен«Требуется аутентификация сервиса»no retry
forbidden403actor без 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).