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

UC-ACT-001. Трекинг сессии гостя и пользователя

Назначение

Описывает end-to-end сценарий жизненного цикла сессии устройства/браузера контекста user-activity (модуль sessions): выдача гостю AnonymousId, продление активности heartbeat-ом, связывание сессии с пользователем при входе (единая история «до и после входа») и закрытие по неактивности фоновым polling-worker-ом. Сценарий — общий корень трекинга: в рамках открытой сессии живут просмотры объектов недвижимости (UC-ACT-002) и лента действий (UC-ACT-003). Дом полей и инвариантов сессии — ../domain/session.md.

Metadata

ПолеЗначение
IDUC-ACT-001
Контекстuser-activity (модуль sessions; см. docs/06-services/user-activity/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольГость (Anonymous, заголовок X-Anonymous-Id) / Инвестор (User, JWT claim sub); фактический исполнитель — система трекинга (фронт + worker activity-closer)
Permissions / scopeпубличный self-scope: спец-permission нет; rate limit по X-Anonymous-Id + IP. Закрытие — system-actor worker-а
Tenantnull — контекст вне tenant (ADR-0052 п.2)
Record-levelself: владелец записи — AnonymousIdUserId после входа); чужие сессии через self-путь недоступны

Предусловие

  • Сервис user-activity доступен (PostgreSQL + Kafka); схема применена.
  • Для гостя: браузер поддерживает хранение AnonymousId (localStorage/cookie) и слать заголовок X-Anonymous-Id.
  • Для связывания: пользователь прошёл вход в identity, фронт держит валидный JWT (claim sub).
  • Порог неактивности InactivityThreshold (10–15 мин, конфиг) и период heartbeat (60 с) заданы конфигурацией.

Триггер

ПолеЗначение
ИсточникAPI call (первое действие устройства) + Schedule (polling-delta worker закрытия)
Триггерящий элементфронт вызывает POST /api/user-activity/sessions при загрузке приложения; heartbeat-таймер (~60 с); POST /sessions/link при входе; worker activity-closer (10–15 мин) — закрытие

Основной Поток

  1. Гость впервые открывает платформу; заголовка X-Anonymous-Id нет. Фронт вызывает POST /api/user-activity/sessions (start-session.md) с User-Agent.
  2. Система создаёт сессию: генерирует AnonymousId, парсит User-Agent в DeviceInfo, Status = Active, CreatedAt = LastActivityAt = now() сервера (INV-SES-004) → 201 { SessionId, AnonymousId, Status = Active, IsNew = true }.
  3. Фронт сохраняет AnonymousId (localStorage) — тот же id владеет гостевыми записями favorites (контракт X-Anonymous-Id) — и хранит SessionId для heartbeat/track.
  4. Пока приложение открыто, фронт раз в ~60 с и на любое track-действие шлёт POST /api/user-activity/sessions/heartbeat (session-heartbeat.md) с SessionId + X-Anonymous-Id. Система условным обновлением по предикату (сессия актора, Status = Active) продлевает LastActivityAt = now() без загрузки агрегата (INV-SES-003) → 200 { Restarted = false }.
  5. Гость авторизуется. Фронт (deterministic-путь) вызывает POST /api/user-activity/sessions/link (link-session.md) с X-Anonymous-Id + JWT. Система находит Active-сессию устройства; исход Linked (UserId == null): связывает сессию с пользователем (UserId: null → value, LinkedAt = now, INV-SES-002), публикует SessionLinkedKafkaEvent200 { Outcome = "Linked" }.
  6. Дальнейшие просмотры/действия наследуют UserId снимком из сессии; гостевая активность «до входа» попадает в историю пользователя без backfill — через связанную сессию («Правило чтения», ../domain/property-object-view.md).
  7. Пользователь уходит/закрывает вкладку — heartbeat прекращается. Через порог неактивности worker activity-closer (../background/activity-closer.md) выбирает Active-сессии с LastActivityAt < now() - threshold (под блокировкой с пропуском занятых), сначала закрывает открытые PropertyObjectView сессии (INV-LV-007), затем закрывает сессию: Active → Inactive, EndedAt = now, Duration = LastActivityAt - CreatedAt (INV-SES-005), публикует SessionEndedKafkaEvent.
  8. SessionEndedKafkaEvent (pin.user-activity.session-ended.v1) потребляет dev-stats (сессионные метрики, ADR-0057). Если устройство вернётся позже — heartbeat/track авто-рестартит новую сессию (INV-SES-003, A2). Retention-job удаляет Inactive-сессии старше 12 мес (конфиг).

Альтернативные Потоки

A1. Восстановление существующей активной сессии (идемпотентный start-session, INV-SES-001)

  1. У устройства уже есть Active-сессия (заголовок X-Anonymous-Id предъявлен). start-session находит её, дубль не создаёт — ветка restore: продлевает LastActivityAt = now().
  2. Ответ 200 { SessionId = existing.Id, IsNew = false }. Гонку двойного старта гасит partial-unique ux_sessions_active_anonymous (при конфликте — перечитка активной и возврат её как restore). Возврат к основному потоку на шаг 4.

A2. Heartbeat/track по закрытой сессии → авто-рестарт (INV-SES-003)

  1. Устройство вернулось после закрытия worker-ом. Условное обновление heartbeat-а затрагивает 0 строк (нет Active-сессии) — не ошибка.
  2. Система создаёт новую сессию (Status = Active, Device — из текущего UA), при наличии JWT рождает её сразу связанной → 200 { SessionId = new, Restarted = true }. Клиент обновляет хранимый SessionId. Возврат к шагу 4.

A3. Вход другого пользователя на устройстве → форс-переключение (INV-SES-002 Switched)

  1. На устройстве уже связанная Active-сессия (UserId != null), входит другой пользователь. link (или consumer, A4): текущая сессия форс-закрывается — Active → Inactive, Duration считается, каждый Active PropertyObjectView сессии закрывается тем же согласованным изменением (INV-LV-007).
  2. Создаётся новая born-linked сессия — уже связанная с новым UserId (Device копируется). Публикуются: SessionEndedKafkaEvent (старая) + PropertyObjectViewEndedKafkaEvent (на каждый закрытый просмотр) + SessionLinkedKafkaEvent (новая). Ответ 200 { Outcome = "Switched", SessionId = new }.

A4. Событийный путь связывания (consumer-дублёр pin.identity.user-signed-in)

  1. identity при входе публикует pin.identity.user-signed-in; UserActivitySignedInConsumer (../consumers/identity-signed-in.md) применяет ту же INV-SES-002 к Active-сессии устройства (корреляция по AnonymousId из payload).
  2. Что сработает раньше — фронтовый link (шаг 5) или consumer — то и связывает; второй попадает в ветку AlreadyLinked → идемпотентный no-op (события нет). Возврат к основному потоку.

A5. Сессия рождается уже связанной (JWT на старте)

  1. Пользователь уже авторизован в момент start-session/авто-рестарта: UserId = ActorUserId, LinkedAt = now() устанавливаются при создании. Это не переход null → valueSessionLinkedKafkaEvent не эмитится (session-linked описывает связывание существующей гостевой сессии). Шаг link не нужен.

A6. Гонка heartbeat и закрытия worker-ом (на границе порога)

  1. Heartbeat и activity-closer могут сойтись на одной Active-сессии на границе InactivityThreshold. Порядок детерминирован: worker берёт Active-строки под построчной блокировкой с пропуском занятых, heartbeat — условное обновление по предикату Status = Active.
  2. Heartbeat выиграл (обновил LastActivityAt первым): строка больше не подходит под предикат worker-а (LastActivityAt < now() - threshold) — worker её пропускает, сессия остаётся Active, ответ 200 { Restarted = false }.
  3. Worker выиграл (закрыл первым): последующий heartbeat затрагивает 0 строк → авто-рестарт новой сессии (ветка A2, Restarted = true). Duration закрытой сессии задним числом не «дозаписывается» (INV-SES-006 InactiveImmutable). Наружу гонка ошибкой не выдаётся.

Постусловие

  • В user_activity.sessions существует запись устройства: Active (пока идёт активность) → Inactive (после закрытия), не более одной Active на AnonymousId (INV-SES-001).
  • После входа сессия имеет заполненные UserId/LinkedAt (INV-SES-002); гостевая активность «до входа» читается в истории пользователя без изменения дочерних записей.
  • Закрытая сессия имеет EndedAt/Duration (INV-SES-005) и неизменна (INV-SES-006); её открытые просмотры закрыты (INV-LV-007).
  • Клиент владеет актуальными AnonymousId (стабилен между сессиями устройства) и SessionId.
  • Опубликованы session-linked (при связывании) и session-ended (при закрытии) атомарно со сменой состояния.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
VALIDATION_FAILED400X-Anonymous-Id не Guid/Guid.Empty; User-Agent пуст или > 1024; SessionId пустИсправить запрос, no retry
USER_ACTIVITY_ANONYMOUS_ID_REQUIRED400heartbeat/link без валидного X-Anonymous-IdПолучить id через start-session, повторить
UNAUTHORIZED401link без/с невалидным JWT (нет claim sub)Повторный вход, затем link
USER_ACTIVITY_SESSION_NOT_FOUND404link: нет Active-сессии для AnonymousIdВызвать start-session, затем повторить (или дождаться consumer A4)
USER_ACTIVITY_SESSION_ACTIVE_RACE409гонка ux_sessions_active_anonymous, перечитка активной пуста (крайне редко)retry (идемпотентно)
USER_ACTIVITY_STORAGE_UNAVAILABLE503PostgreSQL недоступенretry с backoff

Heartbeat по закрытой/отсутствующей сессии — не ошибка: авто-рестарт (200, Restarted = true, INV-SES-003). Гонка двойного start-session и закрытие worker-ом между чтением и мутацией наружу не выдаются (INV-SES-001/003).

Доменные События

СобытиеКогда поднимаетсяSubscribers
SessionStartedEventсоздание сессии (start/авто-рестарт/Switched-новая)in-proc; Kafka-варианта нет
SessionLinkedEventUserId: null → value (link/consumer, исход Linked)in-proc → SessionLinkedKafkaEvent
SessionEndedEventActive → Inactive (закрытие worker-ом / форс-закрытие)in-proc → SessionEndedKafkaEvent
PropertyObjectViewEndedEventзакрытие открытых просмотров при закрытии/Switched сессииin-proc → PropertyObjectViewEndedKafkaEvent

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
SessionLinkedKafkaEvent (ContractVersion = 1)pin.user-activity.session-linked.v1связывание при входе (исходы Linked/Switched-новая)кандидаты crm (привязка гостевой активности к лиду), favorites (триггер merge — фронтовый) — ../events/session-linked.md
SessionEndedKafkaEvent (ContractVersion = 1)pin.user-activity.session-ended.v1закрытие сессии worker-ом / форс-закрытиеdev-stats (сессионные метрики, ADR-0057) — ../events/session-ended.md
PropertyObjectViewEndedKafkaEvent (ContractVersion = 1)pin.user-activity.property-object-view-ended.v1закрытие открытых просмотров при закрытии/Switched сессииuser-activity (инкремент PropertyObjectViewStats), dev-stats, crm../events/property-object-view-ended.md

start-session/heartbeat интеграционных событий не публикуют (session-started вне Kafka). Все интеграционные события публикуются атомарно со сменой состояния; PII (Device.IpAddress/UserAgent) в payload исключён.

Acceptance Criteria

#КритерийПроверка
AC1Первый start-session без заголовка создаёт Active-сессию и выдаёт стабильный AnonymousId (201, IsNew = true)интеграционный тест; повторный старт того же устройства → 200, IsNew = false (INV-SES-001)
AC2Heartbeat продлевает LastActivityAt без загрузки агрегата; по закрытой сессии авто-рестартит (Restarted = true, INV-SES-003)тест закрытой сессии
AC3link гостевой сессии проставляет UserId/LinkedAt один раз и публикует session-linked атомарно со сменой состояния (INV-SES-002)тест публикации; повтор → AlreadyLinked no-op
AC4Вход другого пользователя форс-закрывает текущую (Duration считается) и создаёт born-linked новую; открытые просмотры закрыты (INV-SES-002/INV-LV-007)тест Switched + проверка property_object_views
AC5Worker закрывает сессии по порогу неактивности: Inactive, EndedAt, Duration = LastActivityAt - CreatedAt, идемпотентно, событие session-ended (INV-SES-005/006)тест worker-а + повторный прогон без эффекта
AC6Фронтовый link и consumer sign-ins не конфликтуют: один связывает, второй — идемпотентный no-op (A4)конкурентный тест двух путей
AC7Гостевые просмотры/действия «до входа» видны в истории пользователя без backfill UserId (через связанную сессию)тест «Правила чтения» property-object-view.md
AC8AnonymousId совместим с favorites (тот же id владеет гостевым избранным)контракт-тест X-Anonymous-Id
AC9Гонка heartbeat и activity-closer на границе порога разрешается детерминированно: либо heartbeat продлевает Active (worker пропускает), либо worker закрывает и heartbeat авто-рестартит новую сессию — двойного закрытия/ошибки наружу нет (A6, INV-SES-006)конкурентный тест heartbeat + прогон worker-а

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

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

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