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(FKSessionId).
| Metadata | Значение |
|---|---|
| Owning team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст user-activity, таблица user_activity.sessions |
| Change context | greenfield |
Классификация
| Поле | Значение |
|---|---|
| Контекст | user-activity |
| Kind | AggregateRoot |
| Source of truth | user-activity (этот контекст) |
| Таблица | user_activity.sessions |
| Tenant isolation | не tenanted — данные гостя/инвестора вне tenant (ADR-0052 п.2); изоляция record-level по UserId/AnonymousId |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app (генерируется, сортируемый по времени) | Идентификатор сессии. |
AnonymousId | Guid | Да | 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 (владелец гостевых записей). |
UserId | Guid? | Нет (до входа) | 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). |
LinkedAt | DateTimeOffset? | Нет | — (заполнено ⇔ UserId заполнен, check chk_sessions_link_pair) | app при связывании | Момент связывания сессии с пользователем. |
Status | SessionStatus | Да | varchar(16), enum как text; участвует в partial-индексах | app (Active при создании) / worker (Inactive) | Active — идёт активность; Inactive — закрыта по неактивности или форс-закрыта при смене пользователя. |
CreatedAt | DateTimeOffset | Да | в составе ix_sessions_anonymous_id | app (now() сервера) | Начало сессии (первое действие). |
LastActivityAt | DateTimeOffset | Да | partial ix_sessions_active_last_activity btree(last_activity_at) where status = 'Active' — выборка worker-а | app: heartbeat, всегда now() сервера (INV-SES-004) | Последняя активность устройства (heartbeat фронта раз в 60 с + любое track-действие). |
EndedAt | DateTimeOffset? | Нет (до закрытия) | — | worker activity-closer | Момент фактического закрытия записи (для диагностики лага polling-а). |
Duration | TimeSpan? | Нет (до закрытия) | PG interval; check chk_sessions_duration_nonneg (duration >= interval '0') | worker: LastActivityAt - CreatedAt | Длительность сессии. null, пока Status = Active. |
Device | DeviceInfo | Да | owned-колонки ниже | app: заголовки запроса startSession | Устройство/браузер сессии. |
CHECK-констрейнт связывания: chk_sessions_link_pair — (user_id IS NULL) = (linked_at IS NULL)
(оба заполнены или оба пусты).
Value objects
DeviceInfo
Фиксируется один раз при создании сессии, не обновляется.
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
UserAgent | string | Да | varchar(1024), обрезается по длине | device_user_agent |
DeviceType | DeviceType | Да | varchar(16), enum как text: Desktop / Mobile / Tablet / Bot / Unknown (парсинг UA на сервере) | device_type |
Os | string? | Нет | varchar(128) (парсинг UA: iOS 18, Windows 11, …) | device_os |
Browser | string? | Нет | varchar(128) (парсинг UA: Chrome 138, …) | device_browser |
IpAddress | string? | Нет | varchar(45) (IPv4/IPv6 текстом) | device_ip |
IpAddress — квази-PII: не попадает в Kafka-события и публичные ответы API, в логах маскируется до /24
(см. «Индексы, хранение, безопасность»).
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
Session ← PropertyObjectView | reference (внутри контекста, физическая FK) | PropertyObjectView.SessionId → sessions(id) | выборка просмотров сессии по SessionId | Restrict — retention-job удаляет property_object_views раньше sessions |
Session ← ActionEvent | reference (внутри контекста, физическая FK) | ActionEvent.SessionId → sessions(id) | выборка действий сессии по SessionId | Restrict — порядок очистки в retention-job |
Session → UserEntity (identity) | логическая reference (кросс-контекст, без FK) | UserId | не загружается — id приходит из JWT/события | — |
Session ← Favorite (favorites) | логическая reference (кросс-контекст, без FK) | Favorite.AnonymousId = Session.AnonymousId | favorites проверяет связь через internal API этого контекста (GET /internal/sessions/{anonymousId}/link) | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-SES-001 SingleActivePerDevice | Не более одной Active сессии на AnonymousId. startSession: если активная сессия устройства уже есть — heartbeat вместо создания | старт сессии; страховка — partial-unique ux_sessions_active_anonymous | — (идемпотентный успех) |
| INV-SES-002 LinkOnce | UserId устанавливается один раз (null → value). Повторный вход того же пользователя — no-op. Вход другого пользователя на устройстве с уже связанной Active-сессией — текущая форс-закрывается (Active → Inactive, Duration считается) и создаётся новая Active с новым UserId | связывание сессии / consumer pin.identity.sign-ins.v1 | — (переключение, не ошибка) |
| INV-SES-003 HeartbeatOnlyActive | Heartbeat обновляет только Active-сессию; если активной сессии нет — создаётся новая (устройство вернулось после закрытия) | обработка heartbeat (по числу затронутых строк) | — (авто-restart сессии) |
| INV-SES-004 ServerClockOnly | CreatedAt/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-ended | worker / связывание | — |
| INV-SES-006 InactiveImmutable | Inactive-сессия не изменяется никогда (идемпотентность worker-а: обрабатываются только Active-сессии) | worker, все операции | USER_ACTIVITY_SESSION_ALREADY_ENDED (heartbeat по закрытой → создаётся новая, см. INV-SES-003) |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
SessionStartedEvent | доменное | Создание сессии | SessionId, AnonymousId, UserId? |
SessionLinkedEvent | доменное | UserId: null → value | SessionId, AnonymousId, UserId, LinkedAt |
SessionEndedEvent | доменное | Active → Inactive | SessionId, 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 |
| Concurrency | Heartbeat — условное обновление по предикату Status = Active (без загрузки агрегата); worker берёт Active-строки под блокировкой с пропуском занятых (параллельные инстансы не конфликтуют); partial-unique — страховка гонки двойного startSession |
| Permissions / PII | Self-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), исключён из событий и публичных ответов |
| Audit | CreatedAt/LinkedAt/EndedAt на записи; жизненный цикл воспроизводим по событиям session-linked/session-ended |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: ActionEvent (TPH-иерархия событий действий), Entity / Aggregate: PropertyObjectView, Entity / Aggregate: PropertyObjectViewStats
- API (10): GET /api/internal/user-activity/sessions/{anonymousId}/link — Связка AnonymousId → UserId (merge favorites), GET /api/internal/user-activity/users/{userId}/property-object-activity — Активность инвестора по объектам недвижимости (карточка лида CRM), GET /api/internal/user-activity/users/{userId}/property-objects/view-stats — Батч просмотров инвестора по объектам недвижимости (обогащение карточек CRM), GET /api/internal/user-activity/users/{userId}/timeline — Хронология действий пользователя (карточка лида CRM), POST /api/user-activity/actions — Зафиксировать действие пользователя (track-action), POST /api/user-activity/property-object-views — Открыть просмотр объекта недвижимости, POST /api/user-activity/sessions — Старт/восстановление сессии устройства, POST /api/user-activity/sessions/heartbeat — Heartbeat активности сессии, POST /api/user-activity/sessions/link — Связывание гостевой сессии с пользователем при входе, PUT /api/user-activity/property-object-views/{id}/heartbeat — Продление активности просмотра объекта недвижимости
- Use Cases (6): UC-ACT-001. Трекинг сессии гостя и пользователя, UC-ACT-002. Трекинг длительности и числа просмотров объекта недвижимости, UC-ACT-003. Инвестор просматривает свою историю просмотров и действий, UC-ACT-004. Агент/менеджер видит активность инвестора (для карточки лида), UC-ACT-005. Слияние гостевой и пользовательской активности при входе, UC-UAC-001. Агрегированная активность нескольких инвесторов-участников лида
- События (4): Event Contract: pin.user-activity.action-tracked (ActionTrackedKafkaEvent), Event Contract: pin.user-activity.property-object-view-ended (PropertyObjectViewEndedKafkaEvent), Event Contract: pin.user-activity.session-ended (SessionEndedKafkaEvent), Event Contract: pin.user-activity.session-linked (SessionLinkedKafkaEvent)
- Консюмеры (3): Consumer: Favorites Events (UserActivityFavoriteAddedConsumer / ...RemovedConsumer), Consumer: Identity Sign-Ins (UserActivitySignedInConsumer), Consumer: Selections Events (UserActivitySelectionItemAddedConsumer)
- Фоновые задачи (2): Background Job: ActivityCloserJob, Background Job: ActivityRetentionJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): user-activity Service
Связанные документы
- property-object-view.md, action-event.md — дочерние записи сессии; property-object-view-stats.md — агрегаты просмотров.
- ../README.md — границы контекста, карта API/событий; будущие доки: ../background/activity-closer.md (worker закрытия), ../consumers/identity-signed-in.md (связывание при входе).
- Решения: ADR-0052 (вне tenant), ADR-0054 (модуль
sessions), ADR-0056 п.3 (механизм закрытия), ADR-0057 (dev-stats— потребительsession-ended). - Смежные контексты: ../../favorites/README.md (контракт
X-Anonymous-Id, merge),../../identity/README.md(событиеpin.identity.sign-ins.v1).