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 area | public (self-scope) |
| Feature group | sessions |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/public/link-session.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (JWT обязателен — сценарий входа; UserId из claim sub) |
| Auth policy / Permissions | аутентифицированный пользователь; спец-permission нет (self-scope) |
| Scope (RBAC) | не применяется — контекст вне tenant (ADR-0052 п.2) |
| Record-level | self-scope: связывается только сессия с AnonymousId из заголовка X-Anonymous-Id, предъявленного актором; UserId берётся из JWT, из тела не принимается |
| Tenant isolation | не tenanted — TenantId в запросе нет; изоляция record-level по AnonymousId/UserId (ADR-0052 п.2) |
| Audit | update (link): LinkedAt на записи; жизненный цикл воспроизводим по событию session-linked |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/user-activity/sessions/link |
| Success status | 200 (связано / идемпотентный повтор / переключение — состояние сессии, ресурс не создаётся) |
| Idempotency header | не применяется — естественная идемпотентность по состоянию (INV-SES-002) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
Тело запроса пустое: оба идентификатора — из контекста (сервер-время, клиентские timestamp не принимаются, INV-SES-004).
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserId | identity claims (JWT sub) | Guid | Да | из actor-контекста, не из body (UserId из тела запроса не принимается) | self-scope; не логируется как PII отдельно |
AnonymousId | header X-Anonymous-Id | Guid | Да | валидный Guid, != Guid.Empty; иначе ValidationError | self-scope: идентификатор гостевого устройства актора |
Модель ответа LinkSessionResult
Поля сессии — дом в domain/session.md; здесь проекция ответа.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
SessionId | Guid | Да | Session.Id (при Switched — id новой Active-сессии) | — | Связанная активная сессия. |
AnonymousId | Guid | Да | Session.AnonymousId (echo) | — | Устройство гостя. |
UserId | Guid | Да | actor (sub) | — | Пользователь, с которым связана сессия. |
LinkedAt | DateTimeOffset | Да | Session.LinkedAt (now() сервера) | — | Момент связывания. |
Outcome | LinkOutcome | Да | 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).
Алгоритм
- Контекст и доступ. Актор —
User(JWT обязателен,UserIdиз claimsub);AnonymousIdиз заголовкаX-Anonymous-Id. Контекст вне tenant (ADR-0052 п.2), record-level self-scope поAnonymousId. Идемпотентность естественная — по состоянию сессии (шаг 4, INV-SES-002). - Проверка входа.
AnonymousId != Guid.Empty(заголовок присутствует и парсится в Guid) — иначеUSER_ACTIVITY_ANONYMOUS_ID_REQUIRED(400). Клиентские timestamp не принимаются (INV-SES-004). Активная сессия устройства должна существовать (шаг 3); её отсутствие →USER_ACTIVITY_SESSION_NOT_FOUND(404, фронт должен сначала вызватьstart-session). - Что читаем.
Active-сессию устройства поAnonymousId(единственность гарантирует partial-uniqueux_sessions_active_anonymous; конкурентныеlink/heartbeat сериализуются построчной блокировкой). При исходеSwitchedдополнительно — открытые (Active) просмотры этой сессии для закрытия (обычно единицы). - Бизнес-правила и переходы. Ветвление по
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копируется со старой — то же устройство). Результат — новая сессия.
- A
- Что меняется. A — обновление сессии (
UserId,LinkedAt). C — закрытие просмотров, закрытие старой сессии, создание новой (порядок с учётом FK Restrict: дети, затем родитель, затем новая). B — без изменений. - Какие события фиксируются (дом контрактов — domain/session.md,
domain/property-object-view.md; все
ContractVersion = 1, через outbox):- A:
SessionLinkedKafkaEvent→pin.user-activity.session-linked.v1(AggregateType = "Session", payload{SessionId, AnonymousId, UserId, LinkedAt}). - C:
SessionEndedKafkaEvent→pin.user-activity.session-ended.v1(старая) +PropertyObjectViewEndedKafkaEvent→pin.user-activity.property-object-view-ended.v1на каждый закрытый просмотр +SessionLinkedKafkaEvent(новая). - B: событий нет.
- A:
- Согласованность. Изменения сессий, закрытие просмотров и публикация событий фиксируются в одном согласованном изменении (INV-LV-007) — смена активной сессии устройства не оставляет полусостояний.
- Результат.
{ SessionId, AnonymousId, UserId, LinkedAt, Outcome }→200.SessionId— id результирующейActive-сессии (новой приSwitched).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | SessionLinkedKafkaEvent → pin.user-activity.session-linked.v1 (A и C); при Switched также SessionEndedKafkaEvent → pin.user-activity.session-ended.v1 и PropertyObjectViewEndedKafkaEvent → pin.user-activity.property-object-view-ended.v1 (на закрытый просмотр). Всё через outbox |
| External calls | none (UserId из JWT; существование пользователя не проверяется синхронно) |
| Cache / projections / search | none (PropertyObjectViewStats инкрементируется консюмером property-object-view-ended асинхронно) |
| Notifications | none (кандидаты-потребители session-linked — crm/favorites, domain/session.md) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | X-Anonymous-Id отсутствует / не Guid | «Некорректный идентификатор сессии» | no retry |
UNAUTHORIZED | 401 | нет/невалидный JWT (нет claim sub) | «Требуется авторизация» | no retry (повторный вход) |
USER_ACTIVITY_SESSION_NOT_FOUND | 404 | нет Active-сессии для AnonymousId | «Активная сессия не найдена» | no retry (вызвать start-session, затем повторить или дождаться консюмера sign-ins) |
Гонки исключены построчной блокировкой активной сессии на загрузке (сериализация параллельных link) и
естественной идемпотентностью исхода AlreadyLinked; отдельного кода конфликта нет.
Acceptance-критерии
- Given гостевая
Active-сессия устройства (UserId = null), WhenPOST /sessions/linkс JWTsubиX-Anonymous-Id, Then200,Outcome = Linked,UserIdпроставлен,LinkedAt = now, опубликованsession-linked(INV-SES-002). - Given сессия уже связана с тем же пользователем, When повторный
link, Then200,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, ThenUSER_ACTIVITY_SESSION_NOT_FOUND(404) — клиент сначала вызываетstart-sessionили дожидается консюмераsign-ins. - Given нет валидного JWT (claim
subотсутствует), Whenlink, ThenUNAUTHORIZED(401). - Given заголовок
X-Anonymous-Idотсутствует/неGuid, Whenlink, ThenVALIDATION_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; приSwitched—PreviousSessionId); metrics:user_activity_session_link_total{outcome}, latency p95 (< 200 мс — единичная сессия под построчной блокировкой); traces — сквозной trace черезtraceparent; dashboardpin-user-activity; runbook —docs/06-services/user-activity/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (2): UC-ACT-001. Трекинг сессии гостя и пользователя, UC-ACT-005. Слияние гостевой и пользовательской активности при входе
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: 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 (механизм закрытия).