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

POST /api/user-activity/sessions/link — Связывание гостевой сессии с пользователем при входе

Публичная self-scope команда: при входе (или сразу после регистрации) связать текущую гостевую Active-сессию устройства (X-Anonymous-Id) с пользователем (UserId из JWT-claim sub), реализуя INV-SES-002 LinkOnce (domain/session.md). Даёт единую историю «до и после входа» без backfill UserId на дочерние записи (просмотры/действия читаются через связанную сессию — «Правило чтения», domain/property-object-view.md). Фронтовый путь-дублёр серверного консюмера события pin.identity.sign-ins.v1 (../README.md): что сработает раньше — то и свяжет, второй — идемпотентный no-op.

Назначение

Принять сигнал «пользователь авторизовался на этом устройстве» и перевести гостевую активность в историю пользователя. Три исхода (INV-SES-002): гостевая Active-сессия связывается (UserId: null → value); повторный вход того же пользователя — no-op; вход другого пользователя на устройстве с уже связанной сессией — текущая форс-закрывается (Duration считается, открытые просмотры закрываются) и создаётся новая Active-сессия под новым UserId.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (JWT обязателен — сценарий входа; UserId из claim sub)
Auth policy / Permissionsаутентифицированный пользователь; спец-permission нет (self-scope)
Scope (RBAC)не применяется — контекст вне tenant (ADR-0052 п.2)
Record-levelself-scope: связывается только сессия с AnonymousId из заголовка X-Anonymous-Id, предъявленного актором; UserId берётся из JWT, из тела не принимается
Tenant isolationне tenantedTenantId в запросе нет; изоляция record-level по AnonymousId/UserId (ADR-0052 п.2)
Auditupdate (link): LinkedAt на записи; жизненный цикл воспроизводим по событию session-linked

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/user-activity/sessions/link
Success status200 (связано / идемпотентный повтор / переключение — состояние сессии, ресурс не создаётся)
Idempotency headerне применяется — естественная идемпотентность по состоянию (INV-SES-002)
Correlationсквозной trace через заголовок traceparent

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

Тело запроса пустое: оба идентификатора — из контекста (сервер-время, клиентские timestamp не принимаются, INV-SES-004).

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity claims (JWT sub)GuidДаиз actor-контекста, не из body (UserId из тела запроса не принимается)self-scope; не логируется как PII отдельно
AnonymousIdheader X-Anonymous-IdGuidДавалидный Guid, != Guid.Empty; иначе ValidationErrorself-scope: идентификатор гостевого устройства актора

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

Поля сессии — дом в domain/session.md; здесь проекция ответа.

ПолеТипОбязательностьИсточникMaskingОписание
SessionIdGuidДаSession.Id (при Switched — id новой Active-сессии)Связанная активная сессия.
AnonymousIdGuidДаSession.AnonymousId (echo)Устройство гостя.
UserIdGuidДаactor (sub)Пользователь, с которым связана сессия.
LinkedAtDateTimeOffsetДаSession.LinkedAt (now() сервера)Момент связывания.
OutcomeLinkOutcomeДаcomputed (шаг 4)Linked / AlreadyLinked / Switched (enum строкой).

Предусловия и постусловия

Предусловия:

  • Устройство уже имеет Active-сессию (ранее вызван start-session); её отсутствие → USER_ACTIVITY_SESSION_NOT_FOUND.
  • Предъявлен валидный JWT (claim sub) — сценарий входа/регистрации; заголовок X-Anonymous-Id присутствует.

Постусловия (гарантии после успеха, по исходу):

  • Linked: активная сессия устройства связана с пользователем (UserId: null → value, LinkedAt проставлен); отправлен session-linked.
  • AlreadyLinked: состояние и события не изменились (идемпотентный повтор того же пользователя).
  • Switched: прежняя сессия и все её открытые просмотры закрыты (Active → Inactive, Duration посчитан), создана новая Active-сессия под новым UserId; отправлены session-ended + property-object-view-ended (на каждый закрытый просмотр) + session-linked (новая).
  • Во всех исходах: история «до и после входа» читается по связанной сессии без backfill UserId на дочерние записи; полусостояний нет (INV-LV-007).

Алгоритм

  1. Контекст и доступ. Актор — User (JWT обязателен, UserId из claim sub); AnonymousId из заголовка X-Anonymous-Id. Контекст вне tenant (ADR-0052 п.2), record-level self-scope по AnonymousId. Идемпотентность естественная — по состоянию сессии (шаг 4, INV-SES-002).
  2. Проверка входа. AnonymousId != Guid.Empty (заголовок присутствует и парсится в Guid) — иначе USER_ACTIVITY_ANONYMOUS_ID_REQUIRED (400). Клиентские timestamp не принимаются (INV-SES-004). Активная сессия устройства должна существовать (шаг 3); её отсутствие → USER_ACTIVITY_SESSION_NOT_FOUND (404, фронт должен сначала вызвать start-session).
  3. Что читаем. Active-сессию устройства по AnonymousId (единственность гарантирует partial-unique ux_sessions_active_anonymous; конкурентные link/heartbeat сериализуются построчной блокировкой). При исходе Switched дополнительно — открытые (Active) просмотры этой сессии для закрытия (обычно единицы).
  4. Бизнес-правила и переходы. Ветвление по Session.UserId (INV-SES-002), исход — LinkOutcome:
    • A Linked (UserId == null): связать сессию — UserId = actor, LinkedAt = now(); публикуется SessionLinked. Результат — эта сессия.
    • B AlreadyLinked (UserId == actor): переход отсутствует, no-op (изменений и событий нет). Результат — эта сессия (LinkedAt из записи).
    • C Switched (UserId != null && UserId != actor): текущая сессия форс-закрывается (Active → Inactive, EndedAt = now(), Duration = LastActivityAt − CreatedAt, INV-SES-005), каждый её открытый просмотр закрывается так же (Active → Inactive, EndedAt = now(), Duration = LastActivityAt − CreatedAt, INV-LV-007); затем создаётся новая Active-сессия под новым UserId (LinkedAt = CreatedAt = LastActivityAt = now(), Device копируется со старой — то же устройство). Результат — новая сессия.
  5. Что меняется. A — обновление сессии (UserId, LinkedAt). C — закрытие просмотров, закрытие старой сессии, создание новой (порядок с учётом FK Restrict: дети, затем родитель, затем новая). B — без изменений.
  6. Какие события фиксируются (дом контрактов — domain/session.md, domain/property-object-view.md; все ContractVersion = 1, через outbox):
    • A: SessionLinkedKafkaEventpin.user-activity.session-linked.v1 (AggregateType = "Session", payload {SessionId, AnonymousId, UserId, LinkedAt}).
    • C: SessionEndedKafkaEventpin.user-activity.session-ended.v1 (старая) + PropertyObjectViewEndedKafkaEventpin.user-activity.property-object-view-ended.v1 на каждый закрытый просмотр + SessionLinkedKafkaEvent (новая).
    • B: событий нет.
  7. Согласованность. Изменения сессий, закрытие просмотров и публикация событий фиксируются в одном согласованном изменении (INV-LV-007) — смена активной сессии устройства не оставляет полусостояний.
  8. Результат. { SessionId, AnonymousId, UserId, LinkedAt, Outcome }200. SessionId — id результирующей Active-сессии (новой при Switched).

Диаграмма

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

ТипДетали
Integration eventsSessionLinkedKafkaEventpin.user-activity.session-linked.v1 (A и C); при Switched также SessionEndedKafkaEventpin.user-activity.session-ended.v1 и PropertyObjectViewEndedKafkaEventpin.user-activity.property-object-view-ended.v1 (на закрытый просмотр). Всё через outbox
External callsnone (UserId из JWT; существование пользователя не проверяется синхронно)
Cache / projections / searchnone (PropertyObjectViewStats инкрементируется консюмером property-object-view-ended асинхронно)
Notificationsnone (кандидаты-потребители session-linkedcrm/favorites, domain/session.md)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400X-Anonymous-Id отсутствует / не Guid«Некорректный идентификатор сессии»no retry
UNAUTHORIZED401нет/невалидный JWT (нет claim sub)«Требуется авторизация»no retry (повторный вход)
USER_ACTIVITY_SESSION_NOT_FOUND404нет Active-сессии для AnonymousId«Активная сессия не найдена»no retry (вызвать start-session, затем повторить или дождаться консюмера sign-ins)

Гонки исключены построчной блокировкой активной сессии на загрузке (сериализация параллельных link) и естественной идемпотентностью исхода AlreadyLinked; отдельного кода конфликта нет.

Acceptance-критерии

  • Given гостевая Active-сессия устройства (UserId = null), When POST /sessions/link с JWT sub и X-Anonymous-Id, Then 200, Outcome = Linked, UserId проставлен, LinkedAt = now, опубликован session-linked (INV-SES-002).
  • Given сессия уже связана с тем же пользователем, When повторный link, Then 200, Outcome = AlreadyLinked, изменений и событий нет (идемпотентный no-op).
  • Given сессия связана с другим пользователем, When вход нового пользователя на устройстве, Then 200, Outcome = Switched, SessionId — id новой сессии; старая сессия и её открытые просмотры Inactive с посчитанным Duration; опубликованы session-ended + property-object-view-ended (на каждый просмотр) + session-linked.
  • Given активной сессии устройства нет, When link, Then USER_ACTIVITY_SESSION_NOT_FOUND (404) — клиент сначала вызывает start-session или дожидается консюмера sign-ins.
  • Given нет валидного JWT (claim sub отсутствует), When link, Then UNAUTHORIZED (401).
  • Given заголовок X-Anonymous-Id отсутствует/не Guid, When link, Then VALIDATION_FAILED (400).
  • Given две параллельные link одного устройства, When конкурентно, Then сериализуются построчной блокировкой: один результат Linked, второй AlreadyLinked; полусостояний нет.
  • Given фронтовый link и серверный консюмер pin.identity.sign-ins.v1 сработали оба, When второй доходит после первого, Then он — идемпотентный no-op (AlreadyLinked), двойной привязки нет.

Совместимость и наблюдаемость

  • Тело запроса пустое — новые данные передаются только контекстом/заголовками; добавление optional response полей — additive (не breaking); удаление/переименование/смена типа полей ответа или LinkOutcome — breaking (migration + rollout). Unknown enum на клиенте трактуется как «состояние изменилось».
  • Logs: session-link linked/already-linked/switched (SessionId, AnonymousId, UserId; при SwitchedPreviousSessionId); metrics: user_activity_session_link_total{outcome}, latency p95 (< 200 мс — единичная сессия под построчной блокировкой); traces — сквозной trace через traceparent; dashboard pin-user-activity; runbook — docs/06-services/user-activity/runbooks/ (будущий путь по конвенции).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы

  • Сущности: domain/session.md (INV-SES-002/004/005, события SessionLinked*/ SessionEnded*), domain/property-object-view.md (INV-LV-007, закрытие просмотров).
  • Смежные API sessions: start-session.md, heartbeat.md (будущие пути по конвенции, ../README.md).
  • Серверный путь-дублёр: consumers/identity-signed-in.md (pin.identity.sign-ins.v1); события — events/session-linked.md, events/session-ended.md, events/property-object-view-ended.md (будущие пути).
  • Решения: ADR-0052 (вне tenant), ADR-0054 (модуль sessions), ADR-0056 п.3 (механизм закрытия).