UC-ACT-001. Трекинг сессии гостя и пользователя
Назначение
Описывает end-to-end сценарий жизненного цикла сессии устройства/браузера контекста user-activity
(модуль sessions): выдача гостю AnonymousId, продление активности heartbeat-ом, связывание
сессии с пользователем при входе (единая история «до и после входа») и закрытие по неактивности фоновым
polling-worker-ом. Сценарий — общий корень трекинга: в рамках открытой сессии живут просмотры объектов недвижимости
(UC-ACT-002) и лента действий
(UC-ACT-003). Дом полей и инвариантов сессии —
../domain/session.md.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-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-а |
| Tenant | null — контекст вне tenant (ADR-0052 п.2) |
| Record-level | self: владелец записи — AnonymousId (и UserId после входа); чужие сессии через self-путь недоступны |
Предусловие
- Сервис
user-activityдоступен (PostgreSQL + Kafka); схема применена. - Для гостя: браузер поддерживает хранение
AnonymousId(localStorage/cookie) и слать заголовокX-Anonymous-Id. - Для связывания: пользователь прошёл вход в
identity, фронт держит валидный JWT (claimsub). - Порог неактивности
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 мин) — закрытие |
Основной Поток
- Гость впервые открывает платформу; заголовка
X-Anonymous-Idнет. Фронт вызываетPOST /api/user-activity/sessions(start-session.md) сUser-Agent. - Система создаёт сессию: генерирует
AnonymousId, парситUser-AgentвDeviceInfo,Status = Active,CreatedAt = LastActivityAt = now()сервера (INV-SES-004) →201 { SessionId, AnonymousId, Status = Active, IsNew = true }. - Фронт сохраняет
AnonymousId(localStorage) — тот же id владеет гостевыми записямиfavorites(контрактX-Anonymous-Id) — и хранитSessionIdдля heartbeat/track. - Пока приложение открыто, фронт раз в ~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 }. - Гость авторизуется. Фронт (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), публикуетSessionLinkedKafkaEvent→200 { Outcome = "Linked" }. - Дальнейшие просмотры/действия наследуют
UserIdснимком из сессии; гостевая активность «до входа» попадает в историю пользователя без backfill — через связанную сессию («Правило чтения», ../domain/property-object-view.md). - Пользователь уходит/закрывает вкладку — 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. 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)
- У устройства уже есть
Active-сессия (заголовокX-Anonymous-Idпредъявлен).start-sessionнаходит её, дубль не создаёт — ветка restore: продлеваетLastActivityAt = now(). - Ответ
200 { SessionId = existing.Id, IsNew = false }. Гонку двойного старта гасит partial-uniqueux_sessions_active_anonymous(при конфликте — перечитка активной и возврат её как restore). Возврат к основному потоку на шаг 4.
A2. Heartbeat/track по закрытой сессии → авто-рестарт (INV-SES-003)
- Устройство вернулось после закрытия worker-ом. Условное обновление heartbeat-а затрагивает 0 строк
(нет
Active-сессии) — не ошибка. - Система создаёт новую сессию (
Status = Active,Device— из текущего UA), при наличии JWT рождает её сразу связанной →200 { SessionId = new, Restarted = true }. Клиент обновляет хранимыйSessionId. Возврат к шагу 4.
A3. Вход другого пользователя на устройстве → форс-переключение (INV-SES-002 Switched)
- На устройстве уже связанная
Active-сессия (UserId != null), входит другой пользователь.link(или consumer, A4): текущая сессия форс-закрывается —Active → Inactive,Durationсчитается, каждыйActivePropertyObjectViewсессии закрывается тем же согласованным изменением (INV-LV-007). - Создаётся новая born-linked сессия — уже связанная с новым
UserId(Deviceкопируется). Публикуются:SessionEndedKafkaEvent(старая) +PropertyObjectViewEndedKafkaEvent(на каждый закрытый просмотр) +SessionLinkedKafkaEvent(новая). Ответ200 { Outcome = "Switched", SessionId = new }.
A4. Событийный путь связывания (consumer-дублёр pin.identity.user-signed-in)
identityпри входе публикуетpin.identity.user-signed-in;UserActivitySignedInConsumer(../consumers/identity-signed-in.md) применяет ту же INV-SES-002 кActive-сессии устройства (корреляция поAnonymousIdиз payload).- Что сработает раньше — фронтовый
link(шаг 5) или consumer — то и связывает; второй попадает в веткуAlreadyLinked→ идемпотентный no-op (события нет). Возврат к основному потоку.
A5. Сессия рождается уже связанной (JWT на старте)
- Пользователь уже авторизован в момент
start-session/авто-рестарта:UserId = ActorUserId,LinkedAt = now()устанавливаются при создании. Это не переходnull → value—SessionLinkedKafkaEventне эмитится (session-linked описывает связывание существующей гостевой сессии). Шагlinkне нужен.
A6. Гонка heartbeat и закрытия worker-ом (на границе порога)
- Heartbeat и
activity-closerмогут сойтись на однойActive-сессии на границеInactivityThreshold. Порядок детерминирован: worker берётActive-строки под построчной блокировкой с пропуском занятых, heartbeat — условное обновление по предикатуStatus = Active. - Heartbeat выиграл (обновил
LastActivityAtпервым): строка больше не подходит под предикат worker-а (LastActivityAt < now() - threshold) — worker её пропускает, сессия остаётсяActive, ответ200 { Restarted = false }. - Worker выиграл (закрыл первым): последующий heartbeat затрагивает 0 строк → авто-рестарт новой
сессии (ветка A2,
Restarted = true).Durationзакрытой сессии задним числом не «дозаписывается» (INV-SES-006InactiveImmutable). Наружу гонка ошибкой не выдаётся.
Постусловие
- В
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_FAILED | 400 | X-Anonymous-Id не Guid/Guid.Empty; User-Agent пуст или > 1024; SessionId пуст | Исправить запрос, no retry |
USER_ACTIVITY_ANONYMOUS_ID_REQUIRED | 400 | heartbeat/link без валидного X-Anonymous-Id | Получить id через start-session, повторить |
UNAUTHORIZED | 401 | link без/с невалидным JWT (нет claim sub) | Повторный вход, затем link |
USER_ACTIVITY_SESSION_NOT_FOUND | 404 | link: нет Active-сессии для AnonymousId | Вызвать start-session, затем повторить (или дождаться consumer A4) |
USER_ACTIVITY_SESSION_ACTIVE_RACE | 409 | гонка ux_sessions_active_anonymous, перечитка активной пуста (крайне редко) | retry (идемпотентно) |
USER_ACTIVITY_STORAGE_UNAVAILABLE | 503 | PostgreSQL недоступен | retry с backoff |
Heartbeat по закрытой/отсутствующей сессии — не ошибка: авто-рестарт (200, Restarted = true,
INV-SES-003). Гонка двойного start-session и закрытие worker-ом между чтением и мутацией наружу не
выдаются (INV-SES-001/003).
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
SessionStartedEvent | создание сессии (start/авто-рестарт/Switched-новая) | in-proc; Kafka-варианта нет |
SessionLinkedEvent | UserId: null → value (link/consumer, исход Linked) | in-proc → SessionLinkedKafkaEvent |
SessionEndedEvent | Active → 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) |
| AC2 | Heartbeat продлевает LastActivityAt без загрузки агрегата; по закрытой сессии авто-рестартит (Restarted = true, INV-SES-003) | тест закрытой сессии |
| AC3 | link гостевой сессии проставляет UserId/LinkedAt один раз и публикует session-linked атомарно со сменой состояния (INV-SES-002) | тест публикации; повтор → AlreadyLinked no-op |
| AC4 | Вход другого пользователя форс-закрывает текущую (Duration считается) и создаёт born-linked новую; открытые просмотры закрыты (INV-SES-002/INV-LV-007) | тест Switched + проверка property_object_views |
| AC5 | Worker закрывает сессии по порогу неактивности: 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 |
| AC8 | AnonymousId совместим с favorites (тот же id владеет гостевым избранным) | контракт-тест X-Anonymous-Id |
| AC9 | Гонка heartbeat и activity-closer на границе порога разрешается детерминированно: либо heartbeat продлевает Active (worker пропускает), либо worker закрывает и heartbeat авто-рестартит новую сессию — двойного закрытия/ошибки наружу нет (A6, INV-SES-006) | конкурентный тест heartbeat + прогон worker-а |
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API-методы (дом контракта + алгоритм): ../api/start-session.md, ../api/session-heartbeat.md, ../api/link-session.md.
- Consumer-дублёр связывания: ../consumers/identity-signed-in.md
(
pin.identity.user-signed-in). - Background: ../background/activity-closer.md (закрытие), ../background/activity-retention.md (retention 12 мес).
- Domain entity (дом полей/инвариантов): ../domain/session.md
(INV-SES-001…006,
DeviceInfo,SessionStatus), ../domain/property-object-view.md (INV-LV-007, «Правило чтения»). - События: ../events/session-linked.md, ../events/session-ended.md, ../events/property-object-view-ended.md.
- Смежные UC: UC-ACT-002 (просмотры в сессии), UC-ACT-003 (лента действий).
- Контекст: ../README.md; контракт
X-Anonymous-Id/merge —../../favorites/README.md; вход — ../../identity/events/user-signed-in.md. - Решения: ADR-0052 (вне tenant), ADR-0054 (модуль
sessions), ADR-0056 п.3 (heartbeat/закрытие без Redis).