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 team | PIN Engagement |
| Domain | Поведенческая активность пользователей/гостей |
| Data classification | Internal (квази-PII: IP-адрес устройства — маскируется; SearchQuery — Internal) |
| Runtime criticality | medium (трекинг вне денежного пути; деградация не блокирует просмотр каталога) |
Ownership
| Artifact | Owner/Rule |
|---|---|
| PostgreSQL schema | user_activity |
| API areas | public (self-scope: гость по X-Anonymous-Id, пользователь по JWT) / internal (m2m: crm, catalog, favorites) / admin (ADR-0053, m2m) |
| Owned entities | Session, PropertyObjectView, PropertyObjectViewStats, ActionEvent (TPH: ApplyFilter/Search/ViewPropertyObject/AddToFavorites/RemoveFromFavorites/AddToSelection) |
| Produced events | pin.user-activity.session-linked / session-ended / property-object-view-ended / action-tracked (*.v1) |
| Consumed events | pin.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 избранного при входе (триггер — фронтовый, READMEfavorites); многомерную аналитику застройщика (даты/зрители/ иерархия, 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 | недоступность = недоступность сервиса |
| Kafka | queue | produced (outbox-first) + consumers | деградация: события копятся в outbox, история лида/агрегаты отстают (eventual) |
identity | service/events | actor (JWT claim sub/sid); событие user-signed-in для связывания сессии | недоступность JWT = 401 на публичных командах; событие отстаёт = связывание позже |
favorites | producer/consumer | контракт X-Anonymous-Id; события favorite-added/favorite-removed → ActionEvent | лента действий лида отстаёт |
selections | producer | selection-item-added → AddToSelection ActionEvent | лента подборок отстаёт |
chessboard/catalog | логические ссылки | PropertyObjectId (без FK); display-данные объекта недвижимости добирает потребитель | не влияет на трекинг (id не валидируется синхронно) |
crm | consumer наших событий + internal API | лента карточки лида (B3) | лента лида отстаёт (eventual) |
dev-stats | consumer наших событий | 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/ActionEvent | user-activity | domain/session.md |
PropertyObjectView | Просмотр карточки объекта недвижимости с длительностью и снимком фильтра | user-activity | domain/property-object-view.md |
PropertyObjectViewStats | Инкрементальный счётчик «сколько раз/как долго» по объекту недвижимости | user-activity (производная) | domain/property-object-view-stats.md |
ActionEvent (TPH) | Append-only лента действий (фильтр/поиск/просмотр/избранное/подборка) | user-activity | domain/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-stats—PropertyObjectViewStatsпоPropertyObjectId/батчу дляcatalog(сортировка/бейджи популярности);get-session-link— связкаAnonymousId → UserIdдляfavorites(GET /internal/sessions/{anonymousId}/link, merge).
- Admin: операционные ручки для администратора платформы (ADR-0053, m2m).
Events И Consumers
| Type | Name | Topic | Назначение | Link |
|---|---|---|---|---|
| Produced | SessionLinkedKafkaEvent | pin.user-activity.session-linked.v1 | привязка гостевой активности к пользователю (кандидаты crm/favorites) | events/session-linked.md |
| Produced | SessionEndedKafkaEvent | pin.user-activity.session-ended.v1 | сессионные метрики dev-stats (ADR-0057) | events/session-ended.md |
| Produced | PropertyObjectViewEndedKafkaEvent | pin.user-activity.property-object-view-ended.v1 | инкремент PropertyObjectViewStats (self), facts dev-stats, лента лида crm | events/property-object-view-ended.md |
| Produced | ActionTrackedKafkaEvent | pin.user-activity.action-tracked.v1 | лента действий карточки лида crm, вход рекомендаций news | events/action-tracked.md |
| Consumed | pin.identity.user-signed-in | pin.identity.sign-ins.v1 | UserActivitySignedInConsumer — связывание Active-сессии с UserId | consumers/identity-signed-in.md |
| Consumed | pin.favorites.favorite-added.v1 | favorites | AddToFavoritesActionEvent (дедуп по SourceMessageId, INV-ACT-004) | consumers/favorites-events.md |
| Consumed | pin.favorites.favorite-removed.v1 | favorites | RemoveFromFavoritesActionEvent | consumers/favorites-events.md |
| Consumed | pin.selections.selection-item-added.v1 | selections | AddToSelectionActionEvent (дедуп по SourceMessageId, INV-ACT-004) | consumers/selections-events.md |
| Consumed | pin.user-activity.property-object-view-ended.v1 | user-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-linked | pin.user-activity.session-linked.v1 | SessionId | 1 | at-least-once; дедуп потребителем по MessageId |
session-ended | pin.user-activity.session-ended.v1 | SessionId | 1 | at-least-once; порядок в рамках одного SessionId |
property-object-view-ended | pin.user-activity.property-object-view-ended.v1 | PropertyObjectViewId | 1 | at-least-once; дедуп по PropertyObjectViewId (INV-LVS-003) |
action-tracked | pin.user-activity.action-tracked.v1 | ActionEventId | 1 | at-least-once; append-only, дедуп по ActionEventId |
Совместимость payload — additive (новые optional-поля / новые значения enum не ломают потребителей); удаление/
переименование/смена типа поля или ContractVersion — breaking (координируемый rollout потребителей). Ключ
партиции держит события одного агрегата в одной партиции (порядок per-aggregate); межагрегатного порядка нет.
Что ожидаем от соседей (входные контракты)
identity—pin.identity.user-signed-in(UserId,AnonymousId?,OccurredAt): событийный путь связывания сессии; отсутствие/лаг → связывание отложено (дублируется фронтовымlink-session).favorites—favorite-added/favorite-removed(PropertyObjectId,FavoriteId,MessageId, владелецAnonymousId/UserId): источник производныхAddToFavorites/RemoveFromFavoritesActionEvent, дедуп поSourceMessageId(INV-ACT-004).selections—selection-item-added(SelectionId,PropertyObjectId, владелец): источникAddToSelectionActionEvent, дедуп поSourceMessageId.- Реплики/логические ссылки —
PropertyObjectIdбез FK (владельцыchessboard/catalog): существование синхронно не проверяется (INV-LV-006), display-данные добирает потребитель; устаревание/отсутствие не блокирует трекинг.
Сквозные интеграционные потоки
Поток 1 — единая история «до и после входа» (порядок; async-окна):
- Гость:
start-session→ выданAnonymousId; весь трекинг (views/actions) идёт подAnonymousId(sync). - Вход: либо фронт вызывает
link-session(sync), либо консюмерpin.identity.user-signed-inсвязывает сессию (async) — что сработает раньше, то и связывает; второй путь — идемпотентный no-op (INV-SES-002). - После связывания история читается по связанной сессии без backfill
UserIdна дочерние записи. В окне до связывания (доли секунды при фронтовом пути; лаг консюмера при событийном) до-входные записи подUserIdещё не видны — гость видит их поX-Anonymous-Id.
Поток 2 — жизненный цикл просмотра → счётчики / аналитика / лид (окно eventual-consistency):
start-property-object-view(sync) →Active-просмотр +action-tracked.property-object-view-heartbeatпродлевают активность (sync).activity-closer(async, лаг ≤ порог + один цикл, 10–15 мин) закрывает просмотр, считаетDuration, публикуетproperty-object-view-ended.- Потребители
property-object-view-ended: self-консюмер инкрементитPropertyObjectViewStats(для сортировки каталога),dev-statsпишет facts (ClickHouse),crm— лента лида. Промежуточно: незакрытые просмотры не учтены в счётчиках, аDurationSeconds = nullв истории/лиде.
Поток 3 — действие → лента лида CRM (pull + push):
track-action(ApplyFilter/Search), а такжеstart-property-object-viewи консюмеры favorites/selections пишутActionEvent(sync/async).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 jobs | activity-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_events → property_object_views → sessions) |
| Dashboard | Grafana pin-user-activity (создаётся при реализации) |
| Alerts | лаг консюмеров > 5 мин; outbox backlog > 1000; лаг activity-closer (открытые Active с LastActivityAt старше 2× порога); ошибки worker-ов |
| Feature flags | нет критических |
Безопасность
- Actor types: Гость (
X-Anonymous-Id), Инвестор/пользователь (JWTsub), Агент/Менеджер застройщика (через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 |
| 3 | Tenancy | Вне 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 событий |
Связанные Документы
- Домены: domain/session.md, domain/property-object-view.md,
domain/property-object-view-stats.md, domain/action-event.md
(дом
AppliedFilterModel); событияevents/, консюмерыconsumers/, APIapi/, backgroundbackground/— следующий слой ТЗ (будущие пути по конвенции). - Обзор: technical-assignment.md,
service-boundaries.md (поток «ACT → CRM»),
prototype-mapping.md (
/profile/events). - Смежные: ../crm/README.md (карточка лида — consumer),
../favorites/README.md(X-Anonymous-Id, merge), ../identity/events/user-signed-in.md (связывание при входе),../catalog/README.md(display-данные объектов недвижимости),../dev-stats/README.md(facts). - ADR: 0052 tenancy, 0054 объединение user-activity, 0056 simplicity-first, 0057 dev-stats.