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

UC-ACT-002. Трекинг длительности и числа просмотров объекта недвижимости

Назначение

Описывает сквозной сценарий измерения длительности и подсчёта числа просмотров одной карточки объекта недвижимости: от открытия /lots/[id] (создание Active-PropertyObjectView), через дешёвый heartbeat, до фонового закрытия polling-worker-ом (Duration = LastActivityAt − CreatedAt), публикации pin.user-activity.property-object-view-ended.v1 и инкремента агрегата PropertyObjectViewStats («сколько раз / как долго просматривался объект недвижимости»). Связан с контекстом user-activity (модули views/sessions). Дом полей и инвариантов — domain-доки.

Metadata

ПолеЗначение
IDUC-ACT-002
Контекстuser-activity (модули views/sessions; см. docs/06-services/user-activity/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольГость / Инвестор (авторизованный)
Permissions / scopenone — публичный self-scope трекинг; гость по заголовку X-Anonymous-Id, инвестор по JWT sub; rate limit по AnonymousId + IP-hash
Tenantnull — контекст вне tenant (ADR-0052 п.2)
Record-levelself: владелец AnonymousId (гость) / UserId (инвестор); открытие и heartbeat — только в собственную Session/PropertyObjectView

Предусловие

  • Фронт располагает AnonymousId устройства (получен start-session, хранится в localStorage/cookie) — заголовок X-Anonymous-Id; для инвестора дополнительно валидный JWT (sub).
  • Открыта карточка объекта недвижимости /lots/[id] с корректным PropertyObjectId (Guid; существование в catalog синхронно не проверяется — INV-LV-006).
  • Active-Session устройства существует или будет ensure-нута при открытии просмотра (INV-LV-002 / INV-SES-003).

Триггер

ПолеЗначение
ИсточникUI navigation + периодический heartbeat + Schedule (worker закрытия)
Триггерящий элементОткрытие карточки /lots/[id] на витрине; для закрытия — ActivityCloserJob (polling ~1 мин)

Основной Поток

  1. Пользователь/гость открывает карточку объекта недвижимости /lots/[id] (измеряется длительность просмотра каждого объекта). Фронт располагает X-Anonymous-Id и, для инвестора, JWT.
  2. Фронт вызывает POST /api/user-activity/property-object-views (ручка track-property-object-view) с телом {propertyObjectId, viewSource} и заголовком X-Anonymous-Id; клиентские timestamp не передаются — время ставит сервер (INV-LV-003). Алгоритм — start-property-object-view.md.
  3. Система обеспечивает Active-сессию (ensure по AnonymousId/UserId, INV-SES-003); проверяет дедуп открытого просмотра по паре (SessionId, PropertyObjectId) среди Active (INV-LV-001); подбирает последний ApplyFilterActionEvent сессии (по OccurredAt desc) — источник ApplyFilterEventId/FilterSnapshot («объекты, просмотренные по фильтру»).
  4. Открытого просмотра нет → создаётся просмотр: Status = Active, CreatedAt = LastActivityAt = now(), снимок FilterSnapshot (AppliedFilterModel, INV-LV-005); тем же согласованным изменением создаётся ViewPropertyObjectActionEvent (action-event.md, INV-ACT-002/003) и публикуется action-tracked. Ответ 201 {propertyObjectViewId, sessionId, startedAt, isNew = true}.
  5. Пока карточка открыта, фронт раз в 30–60 с шлёт PUT /api/user-activity/property-object-views/{id}/heartbeat (property-object-view-heartbeat.md): условное обновление LastActivityAt = now() по предикату Status = Active + владелец, без загрузки агрегата (INV-LV-003). Продлевается «жизнь» открытого просмотра.
  6. Пользователь уходит с карточки / закрывает вкладку — heartbeat прекращается; запись остаётся Active. Клиент запись не закрывает (ADR-0056: нет online-TTL/Redis) — закрытие делает фон.
  7. Worker ActivityCloserJob (activity-closer.md, polling ~1 мин) выбирает Active-просмотры с now() − LastActivityAt > InactivityThreshold (10–15 мин, конфиг) под блокировкой с пропуском занятых; закрывает просмотр: Active → Inactive, EndedAt = now(), Duration = LastActivityAt − CreatedAt (INV-LV-004, chk_property_object_views_duration_nonneg); тем же согласованным изменением публикует PropertyObjectViewEndedKafkaEvent (topic pin.user-activity.property-object-view-ended.v1).
  8. Собственный PropertyObjectViewEndedConsumer инкрементирует PropertyObjectViewStats по PropertyObjectId: ViewsCount += 1 (число просмотров), TotalDuration += DurationSeconds (длительность), LastViewedAt = greatest(_, LastActivityAt); идемпотентно по ключу property-object-view-stats:{PropertyObjectViewId} (INV-LVS-003). Так «сколько раз и как долго просматривался объект недвижимости» (ADR-0056 п.2) становится доступно catalog (get-property-object-view-stats) и истории (get-my-history).

Альтернативные Потоки

A1. Повторное открытие уже открытого просмотра (идемпотентность)

  1. Тот же объект недвижимости открыт в существующем Active-просмотре сессии (INV-LV-001): track-property-object-view уходит в heartbeat-ветку — LastActivityAt = now(), ответ 200 {isNew = false}, новая запись не создаётся.
  2. Возврат к шагу 5 (продолжается heartbeat той же записи).

A2. Сессия отсутствует или закрыта на момент открытия

  1. Ensure не нашёл Active-сессии (INV-LV-002 / INV-SES-003) → создаётся новая Active-сессия (DeviceInfo из User-Agent), затем создаётся просмотр. Продолжение с шага 4.

A3. Heartbeat по неактивному / чужому / несуществующему просмотру

  1. Условное обновление затронуло 0 строк → Accepted = false, Status = Inactive, ответ 200 (privacy-preserving: наличие/владение чужой записи наружу не раскрывается). Клиент переоткрывает карточку через track-property-object-view (INV-LV-003). heartbeat-by-id новую запись не создаёт.

A4. Вход инвестора при открытой гостевой карточке

  1. Сессия связывается с UserId (link / consumer pin.identity.user-signed-in); PropertyObjectView.UserId — снимок на момент открытия и остаётся гостевым (INV-LV-005 неприменим к UserId, но backfill не делается).
  2. При закрытии worker берёт актуальный UserId из связанной сессии в payload property-object-view-ended — просмотр попадает в историю пользователя без backfill («Правило чтения», property-object-view.md). Поток не меняется.

A5. Форс-закрытие / закрытие родительской сессии

  1. Сессия закрывается по неактивности или форс-закрывается при входе другого пользователя (INV-SES-002/005): все её Active-просмотры закрываются тем же согласованным изменением (INV-LV-007), на каждый — property-object-view-ended. Далее шаг 8 без изменений.

A6. Дубликат доставки property-object-view-ended (at-least-once)

  1. Повторная доставка того же события: PropertyObjectViewEndedConsumer находит ключ property-object-view-stats:{PropertyObjectViewId} → ack без изменений (INV-LVS-003); счётчики не задваиваются (INV-LVS-002 монотонность).

Постусловие

  • Создана запись PropertyObjectView (после закрытия — Status = Inactive) с измеренной Duration; факт открытия продублирован ViewPropertyObjectActionEvent в ленте действий (/profile/events, карточка лида CRM).
  • Опубликовано pin.user-activity.property-object-view-ended.v1; PropertyObjectViewStats по PropertyObjectId: ViewsCount += 1, TotalDuration += DurationSeconds, LastViewedAt обновлён.
  • История просмотров пополнена: своя — get-my-history; по id инвестора — internal get-user-property-object-activity (карточка лида CRM). Точная многомерная аналитика застройщика — dev-stats/ClickHouse (ADR-0057).
  • catalog видит обновлённую популярность объекта недвижимости через get-property-object-view-stats (сортировка/бейджи).

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
VALIDATION_FAILED400PropertyObjectId == Guid.Empty / не Guid (track-property-object-view); PropertyObjectViewId пустой (heartbeat)Не отправлять; исправить запрос
USER_ACTIVITY_UNAUTHENTICATED401track-property-object-view без X-Anonymous-Id и без JWT sub (владелец не определён)Сначала start-session, затем повтор
USER_ACTIVITY_ANONYMOUS_ID_REQUIRED400heartbeat без X-Anonymous-Id и без JWT subВызвать start-session, переоткрыть просмотр
USER_ACTIVITY_LOT_VIEW_CONFLICT409Гонка двойного open (нарушение ux_property_object_views_active_session_property_object, INV-LV-001)Retry: перечитка → heartbeat-ветка
— (Accepted = false)200Heartbeat по Inactive/чужому/отсутствующему просмотру (A3)Переоткрыть карточку через track-property-object-view

Несуществующий PropertyObjectId и отсутствие/закрытость сессии ошибкой не являются (INV-LV-006, INV-LV-002).

Доменные События

СобытиеКогда поднимаетсяSubscribers
PropertyObjectViewStartedEventСоздание PropertyObjectView (новый просмотр, шаг 4)in-process: порождает ViewPropertyObjectActionEvent тем же согласованным изменением
ActionRecordedEventСоздание ViewPropertyObjectActionEventin-process: публикация action-tracked
PropertyObjectViewEndedEventЗакрытие PropertyObjectView worker-ом (Active → Inactive, шаг 7)in-process: публикация property-object-view-ended

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
ActionTrackedKafkaEventpin.user-activity.action-tracked.v1Создание нового просмотра (ActionType = ViewPropertyObject, шаг 4); heartbeat-ветка не публикуетcrm (лента карточки лида), news (вход рекомендаций)
PropertyObjectViewEndedKafkaEventpin.user-activity.property-object-view-ended.v1Закрытие просмотра worker-ом (шаг 7); payload — property-object-view-ended.md (DurationSeconds, ViewedAt, LastActivityAt, FilterSnapshot?)user-activity (self — инкремент PropertyObjectViewStats), dev-stats (facts ClickHouse), crm (PropertyObjectViewed ленты лида)

Оба (ContractVersion = 1) публикуются атомарно со сменой состояния. PII в payload нет (только id, длительности, снимок фильтра); Device.IpAddress/UserAgent исключены.

Кросс-сервисные контракты и порядок

Предоставляет (payload — action-tracked.md, property-object-view-ended.md):

Событие / TopicКлюч партицииДоставка / порядокГарантия потребителю
ActionTrackedKafkaEvent · pin.user-activity.action-tracked.v1 (v1)AggregateId = ActionEventIdat-least-once; порядок в пределах ключаФакт открытия (ViewPropertyObject) с PropertyObjectId/PropertyObjectViewId/FilterSnapshot?; дедуп потребителя — по ActionEventId
PropertyObjectViewEndedKafkaEvent · pin.user-activity.property-object-view-ended.v1 (v1)AggregateId = PropertyObjectViewIdat-least-once; порядок в пределах ключаЗакрытый просмотр (DurationSeconds, LastActivityAt, актуальный UserId?, FilterSnapshot?); идемпотентность — по PropertyObjectViewId

Ожидает. От catalog/chessboard — ничего синхронно: существование PropertyObjectId при открытии не проверяется (INV-LV-006), битые id отфильтрует выдача каталога/dev-stats. От identity — связывание сессии с UserId (UC-ACT-005) до закрытия просмотра: тогда worker кладёт в property-object-view-ended актуальный UserId, и гостевой просмотр попадает в историю пользователя без backfill (A4); иначе UserId? остаётся null, а просмотр находится по связанной сессии (правило чтения).

Порядок и окно eventual-consistency. Открытие и action-tracked — синхронно с ответом 201 (лента лида crm / вход рекомендаций news — через consumer-лаг, секунды). Пока карточка открыта — запись Active, Duration = null: длительность и инкремент популярности ещё не видны. Закрытие — асинхронно: worker activity-closer после InactivityThreshold (10–15 мин, ADR-0056 п.3) + период polling; лишь затем property-object-view-ended → self-consumer инкрементирует PropertyObjectViewStats (секунды), после чего catalog видит новую популярность, а dev-stats/crm — свои проекции. До закрытия популярность объекта недвижимости не меняется.

Идемпотентность / retry / конкуренция (бизнес-правила). Дубль доставки property-object-view-ended — self-consumer no-op по ключу property-object-view-stats:{PropertyObjectViewId} (INV-LVS-003), счётчики монотонны (INV-LVS-002), не задваиваются (A6). DurationSeconds < 0 — контрактный брак: событие уходит в DLQ по политике консюмера, счётчик не меняется; инфраструктурный сбой консюмера — retry (offset не подтверждается до сохранения). Worker берёт Active-строки под блокировкой с пропуском занятых — одна запись не закрывается двумя инстансами; Inactive иммутабелен (INV-LV-008), повторных property-object-view-ended нет (AC4).

Acceptance Criteria

#КритерийПроверка
AC1Открытие карточки создаёт ровно один Active-PropertyObjectView на пару (SessionId, PropertyObjectId); повтор — heartbeat (200, isNew=false), не новая записьINV-LV-001: два подряд track-property-object-view
AC2Heartbeat продлевает только собственный Active-просмотр; чужой/InactiveAccepted=false (200) без раскрытияконтракт-тест record-level (INV-LV-003)
AC3Worker закрывает просмотр по порогу и считает Duration = LastActivityAt − CreatedAt (>= 0)интеграционный тест activity-closer (INV-LV-004)
AC4На каждый закрытый просмотр публикуется ровно одно property-object-view-ended; Inactive иммутабелен (повторных нет)replay/рестарт worker-а (INV-LV-008)
AC5PropertyObjectViewStats.ViewsCount растёт на 1, TotalDuration — на DurationSeconds; дубликат события не задваиваетconsumer-тест идемпотентности (INV-LVS-002/003)
AC6Гостевой просмотр до входа попадает в историю пользователя (актуальный UserId в property-object-view-ended из связанной сессии)сценарий A4
AC7AvgDuration вычисляется на чтении (TotalDuration / ViewsCount), не хранитсяquery-тест (INV-LVS-005)

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

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

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