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

user-activity Service

Назначение

user-activity владеет схемой user_activity и единой историей поведения гостя/пользователя на платформе PIN: сессии устройства, просмотры объектов недвижимости с измеренной длительностью и фильтр-контекстом, лента действий (фильтры/поиск/избранное/подборки). Объединяет три модуля одного bounded context (ADR-0054):

  • sessions — период активности устройства/браузера: выдача AnonymousId гостю, heartbeat, связывание с UserId при входе, закрытие по неактивности (domain/session.md);
  • views — просмотр карточки объекта недвижимости (PropertyObjectView) с длительностью и снимком применённого фильтра плюс инкрементальный агрегат PropertyObjectViewStats по объекту недвижимости (domain/property-object-view.md, domain/property-object-view-stats.md);
  • actions — append-only TPH-лента ActionEvent (применён фильтр, поиск, открыт объект недвижимости, избранное, подборка), источник события action-tracked для карточки лида CRM (domain/action-event.md).

Единая история поведения покрывает историю просмотров/действий с длительностью и связыванием с сессией, трекинг фильтров/избранного/подборок. Механика жизненного цикла — simplicity-first (ADR-0056): дешёвый heartbeat (продление LastActivityAt без загрузки агрегата), закрытие сессий/просмотров — фоновый polling-delta worker (10–15 мин), без Redis/TTL.

Metadata

ПолеЗначение
Статусdraft
Owning teamPIN Engagement
DomainПоведенческая активность пользователей/гостей
Data classificationInternal (квази-PII: IP-адрес устройства — маскируется; SearchQuery — Internal)
Runtime criticalitymedium (трекинг вне денежного пути; деградация не блокирует просмотр каталога)

Ownership

ArtifactOwner/Rule
PostgreSQL schemauser_activity
API areaspublic (self-scope: гость по X-Anonymous-Id, пользователь по JWT) / internal (m2m: crm, catalog, favorites) / admin (ADR-0053, m2m)
Owned entitiesSession, PropertyObjectView, PropertyObjectViewStats, ActionEvent (TPH: ApplyFilter/Search/ViewPropertyObject/AddToFavorites/RemoveFromFavorites/AddToSelection)
Produced eventspin.user-activity.session-linked / session-ended / property-object-view-ended / action-tracked (*.v1)
Consumed eventspin.identity.user-signed-in, pin.favorites.favorite-added / favorite-removed, pin.selections.selection-item-added, pin.user-activity.property-object-view-ended (self)
Search indexesнет (PostgreSQL btree only, ADR-0056; GIN по filter_snapshot не создаётся)
Analytics factsнет (многомерные facts — dev-stats/ClickHouse, ADR-0057); PropertyObjectViewStats — операционный агрегат в PostgreSQL

Ответственность И Границы

  • Делает: выдаёт AnonymousId гостю (контракт X-Anonymous-Id, владелец гостевой идентичности для favorites); трекинг сессий (start/heartbeat/link/close) с единой историей «до и после входа»; трекинг просмотров объектов недвижимости с длительностью (Duration = LastActivityAt - CreatedAt) и связью «просмотрено по фильтру»; append-only ленту действий (фильтры/поиск own + AddToFavorites/RemoveFrom Favorites/AddToSelection — производные от событий favorites/selections); инкрементальные агрегаты просмотров по объекту недвижимости; выдачу истории (своя self-scope; по id инвестора — через crm/permission); закрытие просроченных сессий/просмотров polling-worker-ом; retention 12 мес.
  • Не делает: авторизацию и выдачу JWT (identity); хранение объектов недвижимости и их display-данных (chessboard/catalog — логические PropertyObjectId без FK, существование не проверяется, INV-LV-006); владение избранным/подборками (favorites/selections — здесь только производные ActionEvent); merge избранного при входе (триггер — фронтовый, README favorites); многомерную аналитику застройщика (даты/зрители/ иерархия, 3 года — dev-stats/ClickHouse, ADR-0057); карточку лида (crm — потребитель наших событий и internal API).
  • Source of truth: Session, PropertyObjectView, ViewPropertyObjectActionEvent — own; AddToFavorites*/AddToSelection — производные записи (исходный факт владеют favorites/selections); PropertyObjectViewStats — производная от собственного потока property-object-view-ended (пересобираема replay-ем топика/dev-stats при потере).
  • Реплики/проекции: UserId/AnonymousId на PropertyObjectView/ActionEvent — snapshot из Session на момент действия; FilterSnapshot — иммутабельный снимок AppliedFilterModel (дом — action-event.md); PropertyObjectViewStats — проекция потока property-object-view-ended.

Зависимости

ЗависимостьТипЗачем НужнаFailure mode
PostgreSQL (user_activity)dbвсе агрегаты, PropertyObjectViewStats, outboxнедоступность = недоступность сервиса
Kafkaqueueproduced (outbox-first) + consumersдеградация: события копятся в outbox, история лида/агрегаты отстают (eventual)
identityservice/eventsactor (JWT claim sub/sid); событие user-signed-in для связывания сессиинедоступность JWT = 401 на публичных командах; событие отстаёт = связывание позже
favoritesproducer/consumerконтракт X-Anonymous-Id; события favorite-added/favorite-removedActionEventлента действий лида отстаёт
selectionsproducerselection-item-addedAddToSelection ActionEventлента подборок отстаёт
chessboard/catalogлогические ссылкиPropertyObjectId (без FK); display-данные объекта недвижимости добирает потребительне влияет на трекинг (id не валидируется синхронно)
crmconsumer наших событий + internal APIлента карточки лида (B3)лента лида отстаёт (eventual)
dev-statsconsumer наших событийfacts ClickHouse из property-object-view-ended/session-ended (ADR-0057)аналитика застройщика отстаёт

Проекты

src/services/user-activity/
Pin.UserActivity.Api/ # public (self-scope) + internal (m2m) + admin области
Pin.UserActivity.Application/ # операции (команды/запросы) и их обработка
Pin.UserActivity.Domain/ # агрегаты, инварианты, доменные события, TPH-лента ActionEvent
Pin.UserActivity.Infrastructure/ # хранилище схемы user_activity, outbox, консюмеры Kafka
Pin.UserActivity.Background/ # worker-ы: activity-closer (polling-delta), activity-retention
Pin.UserActivity.Migrator/ # миграции схемы user_activity (партиционирования нет, ADR-0056)
Pin.UserActivity.Contracts/ # контракты событий pin.user-activity.*

Основные Сущности

EntityНазначениеSource Of TruthДокумент
SessionПериод активности устройства/браузера; корень для PropertyObjectView/ActionEventuser-activitydomain/session.md
PropertyObjectViewПросмотр карточки объекта недвижимости с длительностью и снимком фильтраuser-activitydomain/property-object-view.md
PropertyObjectViewStatsИнкрементальный счётчик «сколько раз/как долго» по объекту недвижимостиuser-activity (производная)domain/property-object-view-stats.md
ActionEvent (TPH)Append-only лента действий (фильтр/поиск/просмотр/избранное/подборка)user-activitydomain/action-event.md

ER-обзор

Поля сущностей — в domain-доках (не дублируются, «один дом на концепт»).

API

Self-scope: гость авторизуется заголовком X-Anonymous-Id, пользователь — JWT (sub). Детальные документы — api/<method>.md.

  • Public (/api/user-activity/..., self-scope):
    • start-session — создать/переиспользовать Active-сессию устройства (INV-SES-001, идемпотентно), выдать AnonymousId;
    • heartbeat — продлить LastActivityAt активной сессии (0 затронутых строк → авто-restart, INV-SES-003);
    • link — связать сессию с UserId из JWT (INV-SES-002; вход другого пользователя форс-закрывает текущую);
    • track-property-object-view — открыть просмотр объекта недвижимости (обеспечивает Active-сессию, снимок фильтра, запись ViewPropertyObjectActionEvent в одном согласованном изменении);
    • property-object-view-heartbeat — продлить LastActivityAt открытого просмотра (INV-LV-003);
    • track-action — записать ApplyFilter/Search (INV-ACT-006 фильтр не пустой; время ставит только сервер);
    • get-my-history — своя лента просмотров/действий с пагинацией (объединение истории «до и после входа» через связанные сессии — «Правило чтения», property-object-view.md).
  • Internal (m2m, /internal/...):
    • get-user-property-object-activity — просмотры пользователя для карточки лида CRM (B3);
    • get-user-action-timeline — лента действий пользователя для карточки лида CRM (B3);
    • get-property-object-view-statsPropertyObjectViewStats по PropertyObjectId/батчу для catalog (сортировка/бейджи популярности);
    • get-session-link — связка AnonymousId → UserId для favorites (GET /internal/sessions/{anonymousId}/link, merge).
  • Admin: операционные ручки для администратора платформы (ADR-0053, m2m).

Events И Consumers

TypeNameTopicНазначениеLink
ProducedSessionLinkedKafkaEventpin.user-activity.session-linked.v1привязка гостевой активности к пользователю (кандидаты crm/favorites)events/session-linked.md
ProducedSessionEndedKafkaEventpin.user-activity.session-ended.v1сессионные метрики dev-stats (ADR-0057)events/session-ended.md
ProducedPropertyObjectViewEndedKafkaEventpin.user-activity.property-object-view-ended.v1инкремент PropertyObjectViewStats (self), facts dev-stats, лента лида crmevents/property-object-view-ended.md
ProducedActionTrackedKafkaEventpin.user-activity.action-tracked.v1лента действий карточки лида crm, вход рекомендаций newsevents/action-tracked.md
Consumedpin.identity.user-signed-inpin.identity.sign-ins.v1UserActivitySignedInConsumer — связывание Active-сессии с UserIdconsumers/identity-signed-in.md
Consumedpin.favorites.favorite-added.v1favoritesAddToFavoritesActionEvent (дедуп по SourceMessageId, INV-ACT-004)consumers/favorites-events.md
Consumedpin.favorites.favorite-removed.v1favoritesRemoveFromFavoritesActionEventconsumers/favorites-events.md
Consumedpin.selections.selection-item-added.v1selectionsAddToSelectionActionEvent (дедуп по SourceMessageId, INV-ACT-004)consumers/selections-events.md
Consumedpin.user-activity.property-object-view-ended.v1user-activity (self)PropertyObjectViewEndedConsumer — инкремент PropertyObjectViewStats (идемпотентно по PropertyObjectViewId)domain/property-object-view-stats.md

Все produced-события имеют ContractVersion = 1 и публикуются через транзакционный outbox в одном согласованном изменении с записью-источником; консюмеры идемпотентны по MessageId/SourceMessageId. PII в payload нет (Device.IpAddress/UserAgent, Favorite.Note исключены); SearchQuery — Internal.

Контракты produced-событий (ключ партиции / версия / гарантия)

СобытиеТопикКлюч партицииВерсияГарантия и дедуп
session-linkedpin.user-activity.session-linked.v1SessionId1at-least-once; дедуп потребителем по MessageId
session-endedpin.user-activity.session-ended.v1SessionId1at-least-once; порядок в рамках одного SessionId
property-object-view-endedpin.user-activity.property-object-view-ended.v1PropertyObjectViewId1at-least-once; дедуп по PropertyObjectViewId (INV-LVS-003)
action-trackedpin.user-activity.action-tracked.v1ActionEventId1at-least-once; append-only, дедуп по ActionEventId

Совместимость payload — additive (новые optional-поля / новые значения enum не ломают потребителей); удаление/ переименование/смена типа поля или ContractVersion — breaking (координируемый rollout потребителей). Ключ партиции держит события одного агрегата в одной партиции (порядок per-aggregate); межагрегатного порядка нет.

Что ожидаем от соседей (входные контракты)

  • identitypin.identity.user-signed-in (UserId, AnonymousId?, OccurredAt): событийный путь связывания сессии; отсутствие/лаг → связывание отложено (дублируется фронтовым link-session).
  • favoritesfavorite-added/favorite-removed (PropertyObjectId, FavoriteId, MessageId, владелец AnonymousId/UserId): источник производных AddToFavorites/RemoveFromFavorites ActionEvent, дедуп по SourceMessageId (INV-ACT-004).
  • selectionsselection-item-added (SelectionId, PropertyObjectId, владелец): источник AddToSelection ActionEvent, дедуп по SourceMessageId.
  • Реплики/логические ссылкиPropertyObjectId без FK (владельцы chessboard/catalog): существование синхронно не проверяется (INV-LV-006), display-данные добирает потребитель; устаревание/отсутствие не блокирует трекинг.

Сквозные интеграционные потоки

Поток 1 — единая история «до и после входа» (порядок; async-окна):

  1. Гость: start-session → выдан AnonymousId; весь трекинг (views/actions) идёт под AnonymousId (sync).
  2. Вход: либо фронт вызывает link-session (sync), либо консюмер pin.identity.user-signed-in связывает сессию (async) — что сработает раньше, то и связывает; второй путь — идемпотентный no-op (INV-SES-002).
  3. После связывания история читается по связанной сессии без backfill UserId на дочерние записи. В окне до связывания (доли секунды при фронтовом пути; лаг консюмера при событийном) до-входные записи под UserId ещё не видны — гость видит их по X-Anonymous-Id.

Поток 2 — жизненный цикл просмотра → счётчики / аналитика / лид (окно eventual-consistency):

  1. start-property-object-view (sync) → Active-просмотр + action-tracked.
  2. property-object-view-heartbeat продлевают активность (sync).
  3. activity-closer (async, лаг ≤ порог + один цикл, 10–15 мин) закрывает просмотр, считает Duration, публикует property-object-view-ended.
  4. Потребители property-object-view-ended: self-консюмер инкрементит PropertyObjectViewStats (для сортировки каталога), dev-stats пишет facts (ClickHouse), crm — лента лида. Промежуточно: незакрытые просмотры не учтены в счётчиках, а DurationSeconds = null в истории/лиде.

Поток 3 — действие → лента лида CRM (pull + push):

  1. track-action (ApplyFilter/Search), а также start-property-object-view и консюмеры favorites/selections пишут ActionEvent (sync/async).
  2. action-tracked (async) → crm накапливает ленту карточки лида; агент при открытии карточки дозагружает историю pull-методом get-user-action-timeline (бэкфилл push-ленты).

Runtime И Эксплуатация

ОбластьТребование
Health checks/health/live, /health/ready (PostgreSQL + Kafka), /health/startup (миграции применены)
SLO/SLAтрекинг-команды (heartbeat/track-*) p95 < 200 мс (продление активности без загрузки агрегата); история/internal-списки p95 < 1 с; лаг закрытия ≤ порог + один polling-цикл
Background jobsactivity-closer (polling-delta 10–15 мин, конфиг; выборка по last_activity_at под построчной блокировкой FOR UPDATE SKIP LOCKED; закрывает property_object_views затем sessions, считает Duration, публикует property-object-view-ended/session-ended; идемпотентен, ADR-0056 п.3); activity-retention (раз в сутки; удаляет батчами Inactive старше 12 мес; порядок action_eventsproperty_object_viewssessions)
DashboardGrafana pin-user-activity (создаётся при реализации)
Alertsлаг консюмеров > 5 мин; outbox backlog > 1000; лаг activity-closer (открытые Active с LastActivityAt старше 2× порога); ошибки worker-ов
Feature flagsнет критических

Безопасность

  • Actor types: Гость (X-Anonymous-Id), Инвестор/пользователь (JWT sub), Агент/Менеджер застройщика (через crm в рамках активного лида, m2m internal), Менеджер ПИН (permission), Admin-система (ADR-0053), система (worker/consumer).
  • Permission model: self-scope (JWT sub / X-Anonymous-Id) — своя история; чужая история по id инвестора — user-activity.history.view-by-user (Менеджер ПИН), агент — через crm (авторизация лида на стороне CRM); user-activity.stats.view — чтение PropertyObjectViewStats (identity/domain/permission.md).
  • Tenant isolation (ADR-0052): вне tenant — данные гостя/инвестора вне tenant (п.2), PropertyObjectViewStats — платформенный агрегат (п.3); tenant-интерсепторы не подключаются, изоляция record-level по UserId/AnonymousId/SessionId — обязательный фильтр на каждом чтении.
  • Sensitive/PII: Device.IpAddress — квази-PII, маскируется в логах до /24, исключён из событий и публичных ответов; SearchQuery — Internal (в логах первые 32 символа); остальное — id и длительности, PII нет.
  • Trusted/untrusted boundary: клиентские timestamp не принимаются — CreatedAt/LastActivityAt/OccurredAt ставит сервер (now(), INV-SES-004/INV-ACT-005); PropertyObjectId/ViewSource недоверенные (формат-валидация, unknown enum → Unknown).
  • Audit: CreatedAt/LinkedAt/EndedAt; жизненный цикл воспроизводим по событиям session-linked/ session-ended/property-object-view-ended; SourceMessageId фиксирует происхождение consumer-записей.

Известные Ограничения И Assumptions

  • Simplicity-first (ADR-0056): PostgreSQL-only, без Redis; закрытие сессий/просмотров — polling-delta worker (лаг до порога + цикл), не онлайн-TTL; целевая нагрузка 1–10k одновременно активных ⇒ тысячи открытых строк, партиционирования нет.
  • Duration неточна на границе цикла: LastActivityAt - CreatedAt не учитывает интервал после последнего heartbeat до закрытия (приемлемо для истории/агрегатов; точная сессионная аналитика — dev-stats).
  • PropertyObjectId существование синхронно не проверяется (INV-LV-006, нет sync-вызова в catalog на каждый просмотр) — битые id отфильтрует выдача каталога/dev-stats.
  • PropertyObjectViewStats — простые суммарные счётчики на объект недвижимости; многомерная аналитика (по датам/зрителям/иерархии проект→здание→планировка, 3 года) — контекст dev-stats на ClickHouse (ADR-0057).
  • Retention 12 мес сырых записей (конфиг); долгосрочная история — dev-stats.

Принятые решения контекста

#ВопросРешение
1Границы контекстаОбъединение sessions/views/actions в один сервис (ADR-0054): общий корень Session, единый heartbeat-механизм, единая история активности
2Жизненный цикл сессий/просмотровADR-0056 п.3: heartbeat — условное продление LastActivityAt только для Status = Active; закрытие — worker activity-closer под построчной блокировкой FOR UPDATE SKIP LOCKED; без Redis/TTL
3TenancyВне tenant (ADR-0052 п.2): record-level по UserId/AnonymousId; гость — X-Anonymous-Id (тот же id владеет записями favorites)
4Имя события действийТопик pin.user-activity.action-tracked.v1 (consumer-facing имя, crm/service-boundaries), один топик с дискриминатором ActionType. Реконсиляция: domain/action-event.md сейчас именует его ActionRecordedKafkaEvent/action-recorded.v1 — привести к action-tracked
5Агрегаты просмотровPropertyObjectViewStats обновляет консюмер собственного property-object-view-ended (не триггеры/не онлайн-подсчёт) — retention сырых property_object_views не портит накопленную статистику (ADR-0056 п.3)
6Разграничение с dev-statsЗдесь — операционные счётчики на объект недвижимости в PostgreSQL; многомерная аналитика (3 года) — dev-stats/ClickHouse (ADR-0057)
7Контекст фильтра просмотраPropertyObjectView.FilterSnapshot — иммутабельный typed jsonb AppliedFilterModel (INV-LV-005): «объекты, просмотренные по фильтру» читаются без join даже после retention событий

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