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
| Поле | Значение |
|---|---|
| ID | UC-ACT-002 |
| Контекст | user-activity (модули views/sessions; см. docs/06-services/user-activity/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Гость / Инвестор (авторизованный) |
| Permissions / scope | none — публичный self-scope трекинг; гость по заголовку X-Anonymous-Id, инвестор по JWT sub; rate limit по AnonymousId + IP-hash |
| Tenant | null — контекст вне tenant (ADR-0052 п.2) |
| Record-level | self: владелец 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 мин) |
Основной Поток
- Пользователь/гость открывает карточку объекта недвижимости
/lots/[id](измеряется длительность просмотра каждого объекта). Фронт располагаетX-Anonymous-Idи, для инвестора, JWT. - Фронт вызывает
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. - Система обеспечивает
Active-сессию (ensure поAnonymousId/UserId, INV-SES-003); проверяет дедуп открытого просмотра по паре (SessionId,PropertyObjectId) средиActive(INV-LV-001); подбирает последнийApplyFilterActionEventсессии (поOccurredAt desc) — источникApplyFilterEventId/FilterSnapshot(«объекты, просмотренные по фильтру»). - Открытого просмотра нет → создаётся просмотр:
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}. - Пока карточка открыта, фронт раз в 30–60 с шлёт
PUT /api/user-activity/property-object-views/{id}/heartbeat(property-object-view-heartbeat.md): условное обновлениеLastActivityAt = now()по предикатуStatus = Active+ владелец, без загрузки агрегата (INV-LV-003). Продлевается «жизнь» открытого просмотра. - Пользователь уходит с карточки / закрывает вкладку — heartbeat прекращается; запись остаётся
Active. Клиент запись не закрывает (ADR-0056: нет online-TTL/Redis) — закрытие делает фон. - 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(topicpin.user-activity.property-object-view-ended.v1). - Собственный
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. Повторное открытие уже открытого просмотра (идемпотентность)
- Тот же объект недвижимости открыт в существующем
Active-просмотре сессии (INV-LV-001):track-property-object-viewуходит в heartbeat-ветку —LastActivityAt = now(), ответ200{isNew = false}, новая запись не создаётся. - Возврат к шагу 5 (продолжается heartbeat той же записи).
A2. Сессия отсутствует или закрыта на момент открытия
- Ensure не нашёл
Active-сессии (INV-LV-002 / INV-SES-003) → создаётся новаяActive-сессия (DeviceInfoизUser-Agent), затем создаётся просмотр. Продолжение с шага 4.
A3. Heartbeat по неактивному / чужому / несуществующему просмотру
- Условное обновление затронуло 0 строк →
Accepted = false,Status = Inactive, ответ200(privacy-preserving: наличие/владение чужой записи наружу не раскрывается). Клиент переоткрывает карточку черезtrack-property-object-view(INV-LV-003). heartbeat-by-id новую запись не создаёт.
A4. Вход инвестора при открытой гостевой карточке
- Сессия связывается с
UserId(link/ consumerpin.identity.user-signed-in);PropertyObjectView.UserId— снимок на момент открытия и остаётся гостевым (INV-LV-005 неприменим кUserId, но backfill не делается). - При закрытии worker берёт актуальный
UserIdиз связанной сессии в payloadproperty-object-view-ended— просмотр попадает в историю пользователя без backfill («Правило чтения», property-object-view.md). Поток не меняется.
A5. Форс-закрытие / закрытие родительской сессии
- Сессия закрывается по неактивности или форс-закрывается при входе другого пользователя (INV-SES-002/005):
все её
Active-просмотры закрываются тем же согласованным изменением (INV-LV-007), на каждый —property-object-view-ended. Далее шаг 8 без изменений.
A6. Дубликат доставки property-object-view-ended (at-least-once)
- Повторная доставка того же события:
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 инвестора — internalget-user-property-object-activity(карточка лида CRM). Точная многомерная аналитика застройщика —dev-stats/ClickHouse (ADR-0057). catalogвидит обновлённую популярность объекта недвижимости черезget-property-object-view-stats(сортировка/бейджи).
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
VALIDATION_FAILED | 400 | PropertyObjectId == Guid.Empty / не Guid (track-property-object-view); PropertyObjectViewId пустой (heartbeat) | Не отправлять; исправить запрос |
USER_ACTIVITY_UNAUTHENTICATED | 401 | track-property-object-view без X-Anonymous-Id и без JWT sub (владелец не определён) | Сначала start-session, затем повтор |
USER_ACTIVITY_ANONYMOUS_ID_REQUIRED | 400 | heartbeat без X-Anonymous-Id и без JWT sub | Вызвать start-session, переоткрыть просмотр |
USER_ACTIVITY_LOT_VIEW_CONFLICT | 409 | Гонка двойного open (нарушение ux_property_object_views_active_session_property_object, INV-LV-001) | Retry: перечитка → heartbeat-ветка |
— (Accepted = false) | 200 | Heartbeat по Inactive/чужому/отсутствующему просмотру (A3) | Переоткрыть карточку через track-property-object-view |
Несуществующий PropertyObjectId и отсутствие/закрытость сессии ошибкой не являются (INV-LV-006, INV-LV-002).
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
PropertyObjectViewStartedEvent | Создание PropertyObjectView (новый просмотр, шаг 4) | in-process: порождает ViewPropertyObjectActionEvent тем же согласованным изменением |
ActionRecordedEvent | Создание ViewPropertyObjectActionEvent | in-process: публикация action-tracked |
PropertyObjectViewEndedEvent | Закрытие PropertyObjectView worker-ом (Active → Inactive, шаг 7) | in-process: публикация property-object-view-ended |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
ActionTrackedKafkaEvent | pin.user-activity.action-tracked.v1 | Создание нового просмотра (ActionType = ViewPropertyObject, шаг 4); heartbeat-ветка не публикует | crm (лента карточки лида), news (вход рекомендаций) |
PropertyObjectViewEndedKafkaEvent | pin.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 = ActionEventId | at-least-once; порядок в пределах ключа | Факт открытия (ViewPropertyObject) с PropertyObjectId/PropertyObjectViewId/FilterSnapshot?; дедуп потребителя — по ActionEventId |
PropertyObjectViewEndedKafkaEvent · pin.user-activity.property-object-view-ended.v1 (v1) | AggregateId = PropertyObjectViewId | at-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 |
| AC2 | Heartbeat продлевает только собственный Active-просмотр; чужой/Inactive → Accepted=false (200) без раскрытия | контракт-тест record-level (INV-LV-003) |
| AC3 | Worker закрывает просмотр по порогу и считает Duration = LastActivityAt − CreatedAt (>= 0) | интеграционный тест activity-closer (INV-LV-004) |
| AC4 | На каждый закрытый просмотр публикуется ровно одно property-object-view-ended; Inactive иммутабелен (повторных нет) | replay/рестарт worker-а (INV-LV-008) |
| AC5 | PropertyObjectViewStats.ViewsCount растёт на 1, TotalDuration — на DurationSeconds; дубликат события не задваивает | consumer-тест идемпотентности (INV-LVS-002/003) |
| AC6 | Гостевой просмотр до входа попадает в историю пользователя (актуальный UserId в property-object-view-ended из связанной сессии) | сценарий A4 |
| AC7 | AvgDuration вычисляется на чтении (TotalDuration / ViewsCount), не хранится | query-тест (INV-LVS-005) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-ACT-001. Трекинг сессии гостя и пользователя
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API методы: ../api/start-property-object-view.md (open-flow,
track-property-object-view), ../api/property-object-view-heartbeat.md (продление), ../api/get-my-history.md (своя история), ../api/get-user-property-object-activity.md (история по id инвестора для CRM), ../api/get-property-object-view-stats.md (счётчики дляcatalog). - Background: ../background/activity-closer.md (закрытие,
Duration). - Событие: ../events/property-object-view-ended.md; домены:
../domain/property-object-view.md (дом
PropertyObjectView, INV-LV-001..008), ../domain/property-object-view-stats.md (инкремент, INV-LVS-001..006), ../domain/session.md (ensure/heartbeat/закрытие), ../domain/action-event.md (ViewPropertyObjectActionEvent,AppliedFilterModel). - Контекст: ../README.md; решения ADR-0052 (вне tenant), ADR-0054 (модуль
views), ADR-0056 п.2–3 (трекинг длительности, polling-закрытие без Redis), ADR-0057 (dev-stats).