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

Entity / Aggregate: Session

Дом полей сущности «Сессия пользователя/гостя» контекста user-activity (модуль sessions). Все проекции (payload событий pin.user-activity.session-*, модели ответов API, контракт X-Anonymous-Id для favorites) ссылаются сюда и перечисляют только дельту.

Назначение

Session — период активности одного устройства/браузера на платформе: от первого действия до закрытия по неактивности. Сессия — общий корень трекинга: связывает гостевую и пользовательскую активность.

Ключевые свойства:

  • гость получает AnonymousId (выдаёт этот контекст) до авторизации — им же пользуется favorites (заголовок X-Anonymous-Id);
  • при входе сессия связывается с пользователем (UserId заполняется), что даёт единую историю «до и после входа» и основу слияния избранного;
  • активность продлевается дешёвым heartbeat, закрытие — фоновый polling-worker (job activity-closer) с событием pin.user-activity.session-ended.v1;
  • Session — корень для PropertyObjectView и ActionEvent (FK SessionId).
MetadataЗначение
Owning teamPIN Engagement
Статус документаdraft
Область действияконтекст user-activity, таблица user_activity.sessions
Change contextgreenfield

Классификация

ПолеЗначение
Контекстuser-activity
KindAggregateRoot
Source of truthuser-activity (этот контекст)
Таблицаuser_activity.sessions
Tenant isolationне tenanted — данные гостя/инвестора вне tenant (ADR-0052 п.2); изоляция record-level по UserId/AnonymousId

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKapp (генерируется, сортируемый по времени)Идентификатор сессии.
AnonymousIdGuidДаix_sessions_anonymous_id btree(anonymous_id, created_at desc); partial-unique ux_sessions_active_anonymous (anonymous_id) where status = 'Active' (INV-SES-001)app: выдаётся этим контекстом при первом startSession; далее приходит заголовком X-Anonymous-IdИдентификатор устройства/браузера гостя. Стабилен между сессиями одного устройства (фронт хранит в localStorage/cookie). Тот же id использует favorites (владелец гостевых записей).
UserIdGuid?Нет (до входа)ix_sessions_user_id btree(user_id) where user_id is not null; логическая ссылка identity.users(id)без физической FK (чужой контекст)consumer pin.identity.sign-ins.v1 либо POST /sessions/link (JWT claim sub)Пользователь, с которым связана сессия. null — сессия гостевая. Заполняется один раз (INV-SES-002).
LinkedAtDateTimeOffset?Нет— (заполнено ⇔ UserId заполнен, check chk_sessions_link_pair)app при связыванииМомент связывания сессии с пользователем.
StatusSessionStatusДаvarchar(16), enum как text; участвует в partial-индексахapp (Active при создании) / worker (Inactive)Active — идёт активность; Inactive — закрыта по неактивности или форс-закрыта при смене пользователя.
CreatedAtDateTimeOffsetДав составе ix_sessions_anonymous_idapp (now() сервера)Начало сессии (первое действие).
LastActivityAtDateTimeOffsetДаpartial ix_sessions_active_last_activity btree(last_activity_at) where status = 'Active' — выборка worker-аapp: heartbeat, всегда now() сервера (INV-SES-004)Последняя активность устройства (heartbeat фронта раз в 60 с + любое track-действие).
EndedAtDateTimeOffset?Нет (до закрытия)worker activity-closerМомент фактического закрытия записи (для диагностики лага polling-а).
DurationTimeSpan?Нет (до закрытия)PG interval; check chk_sessions_duration_nonneg (duration >= interval '0')worker: LastActivityAt - CreatedAtДлительность сессии. null, пока Status = Active.
DeviceDeviceInfoДаowned-колонки нижеapp: заголовки запроса startSessionУстройство/браузер сессии.

CHECK-констрейнт связывания: chk_sessions_link_pair(user_id IS NULL) = (linked_at IS NULL) (оба заполнены или оба пусты).

Value objects

DeviceInfo

Фиксируется один раз при создании сессии, не обновляется.

ПолеТипОбязательностьОграниченияКолонка
UserAgentstringДаvarchar(1024), обрезается по длинеdevice_user_agent
DeviceTypeDeviceTypeДаvarchar(16), enum как text: Desktop / Mobile / Tablet / Bot / Unknown (парсинг UA на сервере)device_type
Osstring?Нетvarchar(128) (парсинг UA: iOS 18, Windows 11, …)device_os
Browserstring?Нетvarchar(128) (парсинг UA: Chrome 138, …)device_browser
IpAddressstring?Нетvarchar(45) (IPv4/IPv6 текстом)device_ip

IpAddress — квази-PII: не попадает в Kafka-события и публичные ответы API, в логах маскируется до /24 (см. «Индексы, хранение, безопасность»).

Модели JSONB

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
Session ← PropertyObjectViewreference (внутри контекста, физическая FK)PropertyObjectView.SessionId → sessions(id)выборка просмотров сессии по SessionIdRestrict — retention-job удаляет property_object_views раньше sessions
Session ← ActionEventreference (внутри контекста, физическая FK)ActionEvent.SessionId → sessions(id)выборка действий сессии по SessionIdRestrict — порядок очистки в retention-job
Session → UserEntity (identity)логическая reference (кросс-контекст, без FK)UserIdне загружается — id приходит из JWT/события
Session ← Favorite (favorites)логическая reference (кросс-контекст, без FK)Favorite.AnonymousId = Session.AnonymousIdfavorites проверяет связь через internal API этого контекста (GET /internal/sessions/{anonymousId}/link)

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-SES-001 SingleActivePerDeviceНе более одной Active сессии на AnonymousId. startSession: если активная сессия устройства уже есть — heartbeat вместо созданиястарт сессии; страховка — partial-unique ux_sessions_active_anonymous— (идемпотентный успех)
INV-SES-002 LinkOnceUserId устанавливается один раз (null → value). Повторный вход того же пользователя — no-op. Вход другого пользователя на устройстве с уже связанной Active-сессией — текущая форс-закрывается (Active → Inactive, Duration считается) и создаётся новая Active с новым UserIdсвязывание сессии / consumer pin.identity.sign-ins.v1— (переключение, не ошибка)
INV-SES-003 HeartbeatOnlyActiveHeartbeat обновляет только Active-сессию; если активной сессии нет — создаётся новая (устройство вернулось после закрытия)обработка heartbeat (по числу затронутых строк)— (авто-restart сессии)
INV-SES-004 ServerClockOnlyCreatedAt/LastActivityAt/EndedAt ставит только сервер (now()); клиентские timestamp-ы не принимаютсявсе операции записиValidationError (лишние поля отвергаются контрактом)
INV-SES-005 CloseComputesDurationПереход Active → Inactive выполняют только worker activity-closer (порог неактивности) и связывание при входе другого пользователя (INV-SES-002); в одном согласованном изменении: Duration = LastActivityAt - CreatedAt, EndedAt = now(), закрытие открытых PropertyObjectView сессии (INV-LV-007), публикация session-endedworker / связывание
INV-SES-006 InactiveImmutableInactive-сессия не изменяется никогда (идемпотентность worker-а: обрабатываются только Active-сессии)worker, все операцииUSER_ACTIVITY_SESSION_ALREADY_ENDED (heartbeat по закрытой → создаётся новая, см. INV-SES-003)

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
SessionStartedEventдоменноеСоздание сессииSessionId, AnonymousId, UserId?
SessionLinkedEventдоменноеUserId: null → valueSessionId, AnonymousId, UserId, LinkedAt
SessionEndedEventдоменноеActive → InactiveSessionId, Duration
SessionLinkedKafkaEventинтеграционное (ContractVersion = 1, topic pin.user-activity.session-linked.v1, AggregateId = SessionId, AggregateType = "Session")Связывание при входеSessionId, AnonymousId, UserId, LinkedAt. Обязательных потребителей нет; кандидаты — crm (привязка гостевой активности к лиду), favorites (триггер merge выбран фронтовый — README favorites)
SessionEndedKafkaEventинтеграционное (ContractVersion = 1, topic pin.user-activity.session-ended.v1, AggregateId = SessionId)Закрытие worker-ом/форс-закрытиеSessionId, AnonymousId, UserId?, CreatedAt, LastActivityAt, DurationSeconds (int, из Duration). Consumers: dev-stats (сессионные метрики, ADR-0057). Контракт — ../events/session-ended.md

Интеграционные события публикуются атомарно со сменой состояния агрегата. Device.IpAddress/UserAgent в интеграционные события не включаются.

Связывание не backfill-ит детей. Переход UserId: null → value (INV-SES-002) не переписывает UserId уже созданных дочерних PropertyObjectView/ActionEvent — они хранят снимок на момент действия (возможно null для действий «до входа»). Единая история «до и после входа» собирается на чтении через принадлежность записи сессии («Правило чтения» в property-object-view.md), а не массовым обновлением. Просмотры, закрывающиеся после связывания, при этом получают актуальный UserId в своём событии property-object-view-ended (значение перечитывается из сессии на момент закрытия).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK btree(id); ux_sessions_active_anonymous unique btree(anonymous_id) where status = 'Active'; ix_sessions_anonymous_id btree(anonymous_id, created_at desc) — история сессий устройства; ix_sessions_user_id btree(user_id) where user_id is not null — история по пользователю; ix_sessions_active_last_activity btree(last_activity_at) where status = 'Active' — выборка worker-а
Партиционирование / retentionБез партиционирования (ADR-0056: 1–10k активных ⇒ тысячи открытых строк). Retention: job activity-retention раз в сутки удаляет Inactive-сессии старше 12 мес (конфиг) батчами после очистки зависимых action_events/property_object_views
ConcurrencyHeartbeat — условное обновление по предикату Status = Active (без загрузки агрегата); worker берёт Active-строки под блокировкой с пропуском занятых (параллельные инстансы не конфликтуют); partial-unique — страховка гонки двойного startSession
Permissions / PIISelf-scope: гость по X-Anonymous-Id, пользователь по JWT. Чужие сессии: crm — m2m internal API (авторизация «активный лид» на стороне CRM); администратор платформы — m2m admin API (ADR-0053); Менеджер ПИН — permission user-activity.history.view-by-user. Device.IpAddress — квази-PII: маскируется в логах (до /24), исключён из событий и публичных ответов
AuditCreatedAt/LinkedAt/EndedAt на записи; жизненный цикл воспроизводим по событиям session-linked/session-ended

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

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

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