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

PUT /api/user-activity/property-object-views/{id}/heartbeat — Продление активности просмотра объекта недвижимости

Дешёвый heartbeat открытой карточки объекта недвижимости: пока пользователь/гость держит карточку /lots/[id] открытой, фронт раз в 30–60 с шлёт этот запрос, продлевая LastActivityAt активного PropertyObjectView (INV-LV-003). Записи ничего не закрывает — переход Active → Inactive, расчёт Duration и событие property-object-view-ended выполняет фоновый worker activity-closer (polling-delta 10–15 мин, ADR-0056 п.3). Парная операция к track-property-object-view (open-flow) — тот же механизм, что heartbeat сессии (session.md).

Назначение

Продлить срок жизни открытого просмотра объекта недвижимости одним дешёвым условным изменением без загрузки агрегата (SLO трекинг-команд p95 < 200 мс, README). Сервер ставит LastActivityAt = now(); клиентские timestamp не принимаются (INV-LV-003, server-clock-only).

Metadata

ПолеЗначение
Сервис/контекстuser-activity (модуль views)
API areapublic (self-scope: гость по X-Anonymous-Id, пользователь по JWT)
Feature groupviews
Статусdraft
Документdocs/06-services/user-activity/api/public/property-object-view-heartbeat.md

Актор и доступ

ПроверкаЗначение
Actor typeAnonymous (гость, заголовок X-Anonymous-Id) или User (UserId из JWT claim sub)
Auth policy / Permissionsnone (публичный self-scope метод)
Scope (RBAC)не применяется (актор вне tenant, ADR-0052 п.2)
Record-levelвладелец записи: AnonymousId (гость) / UserId (пользователь); предикат Update ограничен владельцем — чужой просмотр не затрагивается и не раскрывается
Tenant isolationне применяется — user-activity вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются
Auditnone (частая идемпотентная трекинг-операция; PII нет)

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/user-activity/property-object-views/{id}/heartbeat
Success status200 (тело — результат ниже; Accepted различает продление и «просмотр не активен»)
Idempotency headerне применяется — естественная идемпотентность PUT: повтор просто перезаписывает LastActivityAt = now()
Correlationсквозной trace через заголовок traceparent

Тело запроса пустое: heartbeat не несёт полей; LastActivityAt ставит сервер (INV-LV-003). Любые клиентские timestamp в теле отвергаются контрактом (лишние поля).

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PropertyObjectViewIdroute {id}GuidДа!= Guid.Empty (формат Guid; существование проверяет условное изменение, шаг 3)
AnonymousIdheader X-Anonymous-IdGuidДа для гостя (иначе UserId)valid Guid; ключ record-level владенияне логируется в открытом виде
ActorUserIdidentity claims (JWT sub)Guid?Нетзаполнен для авторизованного пользователя

Инвариант владельца: должен присутствовать хотя бы один идентификатор актора (X-Anonymous-Id или JWT sub); иначе запрос отклоняется (USER_ACTIVITY_ANONYMOUS_ID_REQUIRED).

Модель ответа PropertyObjectViewHeartbeatResult

ПолеТипОбязательностьИсточникMaskingОписание
PropertyObjectViewIdGuidДаroute echoИдентификатор просмотра из запроса.
AcceptedboolДаcomputed (шаг 3: затронута 1 строка)true — активный просмотр продлён; false — под этим id нет активного просмотра актора.
StatusPropertyObjectViewStatus (enum строкой)ДаcomputedActive при Accepted; иначе Inactive — клиенту следует переоткрыть карточку через track-property-object-view.
LastActivityAtDateTimeOffsetДасервер now()Записанное значение (при Accepted); при Accepted = false — момент обработки (информативно).

Accepted = false объединяет случаи «просмотр Inactive», «id не существует» и «принадлежит другому актору» — это privacy-preserving: наличие/владение чужой записи наружу не раскрывается (record-level, ADR-0052).

Предусловия и постусловия

Предусловия:

  • Клиент держит открытой карточку объекта недвижимости и знает PropertyObjectViewId (получен из track-property-object-view).
  • Присутствует хотя бы один идентификатор актора (X-Anonymous-Id или JWT sub).

Постусловия (гарантии после успеха):

  • При Accepted = true: у собственного Active-просмотра LastActivityAt = now; перехода состояния, Duration/EndedAt, доменных/интеграционных событий нет.
  • При Accepted = false: состояние не изменено; новая запись heartbeat-ом по id не создаётся — переоткрытие делает клиент через track-property-object-view (INV-LV-003).

Алгоритм

Тривиальное продление активности одним условным изменением, без загрузки агрегата (ADR-0056 п.3, цель p95 < 200 мс).

  1. Контекст и доступ. Актор — Anonymous (X-Anonymous-Id) или User (UserId из JWT sub), self-scope, вне tenant (ADR-0052 п.2). Идемпотентность естественная (перезапись LastActivityAt = now()).
  2. Проверка входа. PropertyObjectViewId != Guid.Empty — иначе VALIDATION_FAILED (400). Должен присутствовать хотя бы один идентификатор владельца (X-Anonymous-Id или JWT sub) — иначе USER_ACTIVITY_ANONYMOUS_ID_REQUIRED (400). Агрегат отдельным чтением не загружается: существование, Status и владелец проверяются самим условным изменением (шаг 3). Клиентские timestamp не принимаются (INV-LV-003).
  3. Что меняется. Единственное поле — LastActivityAt = now() (сервер, INV-LV-003), только если просмотр Active и принадлежит актору (владелец AnonymousId/UserId встроен в условие — record-level, ADR-0052). Перехода состояния нет (Active → Active); Duration/EndedAt не трогаются (их считает activity-closer при закрытии, INV-LV-004; оптимистическая блокировка не участвует, INV-LV-008). Затронута одна строка → Accepted = true. Затронуто ноль строк → Accepted = false: просмотр Inactive/отсутствует/чужой — по INV-LV-003 карточка считается переоткрытой; новую запись heartbeat-by-id не создаёт (нет SessionId/PropertyObjectId) — переоткрытие делает клиент через track-property-object-view. Доменных событий нет (heartbeat не меняет жизненный цикл; PropertyObjectViewEndedKafkaEvent в топик pin.user-activity.property-object-view-ended.v1 публикует worker activity-closer при закрытии).
  4. Результат. { PropertyObjectViewId, Accepted, Status = Accepted ? Active : Inactive, LastActivityAt = now }200. Accepted = false сигнализирует клиенту переоткрыть просмотр (track-property-object-view).

Побочные эффекты

ТипДетали
Integration eventsnone (heartbeat не эмитит; property-object-view-ended публикует worker activity-closer при закрытии)
External callsnone
Cache / projections / searchnone (PropertyObjectViewStats инкрементирует только консюмер property-object-view-ended, property-object-view-stats.md)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400PropertyObjectViewId не Guid / пустой«Проверьте идентификатор просмотра»no retry
USER_ACTIVITY_ANONYMOUS_ID_REQUIRED400нет ни X-Anonymous-Id, ни JWT sub«Не указан идентификатор устройства»no retry (клиент вызывает start-session)

Accepted = false (просмотр не активен/отсутствует/чужой) — не ошибка, а штатный ответ 200: клиент переоткрывает карточку через track-property-object-view (INV-LV-003). Инфраструктурный сбой БД → 503, retry по политике клиента (heartbeat вне денежного пути, потеря одного удара некритична).

Acceptance-критерии

  • Given собственный Active-просмотр, When PUT /property-object-views/{id}/heartbeat, Then 200, Accepted = true, Status = Active, LastActivityAt продлён; событий нет.
  • Given просмотр уже Inactive (закрыт worker-ом), When heartbeat, Then 200, Accepted = false, Status = Inactive; клиент переоткрывает через track-property-object-view.
  • Given PropertyObjectViewId принадлежит другому актору или не существует, When heartbeat, Then 200, Accepted = false (privacy-preserving: наличие/владение чужой записи не раскрывается).
  • Given отсутствует и X-Anonymous-Id, и JWT sub, When heartbeat, Then USER_ACTIVITY_ANONYMOUS_ID_REQUIRED (400).
  • Given PropertyObjectViewId = Guid.Empty/не Guid, When heartbeat, Then VALIDATION_FAILED (400).
  • Given PostgreSQL недоступен, When heartbeat, Then 503; потеря одного удара некритична (следующий heartbeat продлит).

Совместимость и наблюдаемость

  • Новые optional-поля результата — без breaking change; удаление/переименование/смена типа Status/Accepted — breaking (migration + rollout). PropertyObjectViewStatus — закрытый enum строкой; на входе enum нет (тело пустое), unknown-обработка неприменима.
  • Logs: property-object-view-heartbeat accepted|stale (PropertyObjectViewId, Accepted; X-Anonymous-Id — не в открытом виде, PII нет); metrics: user_activity_property_object_view_heartbeat_total{result=accepted|stale}, latency p95 (SLO < 200 мс); traces — сквозной trace через traceparent; dashboard pin-user-activity; runbook — лаг activity-closer (открытые Active с LastActivityAt старше 2× порога).

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

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

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

  • Сущности: property-object-view.md (дом полей/инвариантов PropertyObjectView, INV-LV-003/004/008), session.md (парный механизм heartbeat/закрытия сессии).
  • Смежные API (следующий слой ТЗ, будущие пути): track-property-object-view.md (open-flow / переоткрытие при Accepted = false), session-heartbeat.md.
  • Закрытие и событие: ../background/activity-closer.md (переход Active → Inactive, Duration, outbox property-object-view-ended), ../events/property-object-view-ended.md.
  • Контекст: ../README.md; решения ADR-0052 (вне tenant), ADR-0054 (модуль views), ADR-0056 п.3 (heartbeat/polling-close без Redis).