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 area | public (self-scope: гость по X-Anonymous-Id, пользователь по JWT) |
| Feature group | views |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/public/property-object-view-heartbeat.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Anonymous (гость, заголовок X-Anonymous-Id) или User (UserId из JWT claim sub) |
| Auth policy / Permissions | none (публичный self-scope метод) |
| Scope (RBAC) | не применяется (актор вне tenant, ADR-0052 п.2) |
| Record-level | владелец записи: AnonymousId (гость) / UserId (пользователь); предикат Update ограничен владельцем — чужой просмотр не затрагивается и не раскрывается |
| Tenant isolation | не применяется — user-activity вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются |
| Audit | none (частая идемпотентная трекинг-операция; PII нет) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/user-activity/property-object-views/{id}/heartbeat |
| Success status | 200 (тело — результат ниже; Accepted различает продление и «просмотр не активен») |
| Idempotency header | не применяется — естественная идемпотентность PUT: повтор просто перезаписывает LastActivityAt = now() |
| Correlation | сквозной trace через заголовок traceparent |
Тело запроса пустое: heartbeat не несёт полей; LastActivityAt ставит сервер (INV-LV-003). Любые
клиентские timestamp в теле отвергаются контрактом (лишние поля).
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
PropertyObjectViewId | route {id} | Guid | Да | != Guid.Empty (формат Guid; существование проверяет условное изменение, шаг 3) | — |
AnonymousId | header X-Anonymous-Id | Guid | Да для гостя (иначе UserId) | valid Guid; ключ record-level владения | не логируется в открытом виде |
ActorUserId | identity claims (JWT sub) | Guid? | Нет | заполнен для авторизованного пользователя | — |
Инвариант владельца: должен присутствовать хотя бы один идентификатор актора (X-Anonymous-Id или
JWT sub); иначе запрос отклоняется (USER_ACTIVITY_ANONYMOUS_ID_REQUIRED).
Модель ответа PropertyObjectViewHeartbeatResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
PropertyObjectViewId | Guid | Да | route echo | — | Идентификатор просмотра из запроса. |
Accepted | bool | Да | computed (шаг 3: затронута 1 строка) | — | true — активный просмотр продлён; false — под этим id нет активного просмотра актора. |
Status | PropertyObjectViewStatus (enum строкой) | Да | computed | — | Active при Accepted; иначе Inactive — клиенту следует переоткрыть карточку через track-property-object-view. |
LastActivityAt | DateTimeOffset | Да | сервер now() | — | Записанное значение (при Accepted); при Accepted = false — момент обработки (информативно). |
Accepted = false объединяет случаи «просмотр Inactive», «id не существует» и «принадлежит другому актору» —
это privacy-preserving: наличие/владение чужой записи наружу не раскрывается (record-level, ADR-0052).
Предусловия и постусловия
Предусловия:
- Клиент держит открытой карточку объекта недвижимости и знает
PropertyObjectViewId(получен изtrack-property-object-view). - Присутствует хотя бы один идентификатор актора (
X-Anonymous-Idили JWTsub).
Постусловия (гарантии после успеха):
- При
Accepted = true: у собственногоActive-просмотраLastActivityAt = now; перехода состояния,Duration/EndedAt, доменных/интеграционных событий нет. - При
Accepted = false: состояние не изменено; новая запись heartbeat-ом по id не создаётся — переоткрытие делает клиент черезtrack-property-object-view(INV-LV-003).
Алгоритм
Тривиальное продление активности одним условным изменением, без загрузки агрегата (ADR-0056 п.3, цель p95 < 200 мс).
- Контекст и доступ. Актор —
Anonymous(X-Anonymous-Id) илиUser(UserIdиз JWTsub), self-scope, вне tenant (ADR-0052 п.2). Идемпотентность естественная (перезаписьLastActivityAt = now()). - Проверка входа.
PropertyObjectViewId != Guid.Empty— иначеVALIDATION_FAILED(400). Должен присутствовать хотя бы один идентификатор владельца (X-Anonymous-Idили JWTsub) — иначеUSER_ACTIVITY_ANONYMOUS_ID_REQUIRED(400). Агрегат отдельным чтением не загружается: существование,Statusи владелец проверяются самим условным изменением (шаг 3). Клиентские timestamp не принимаются (INV-LV-003). - Что меняется. Единственное поле —
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публикует workeractivity-closerпри закрытии). - Результат.
{ PropertyObjectViewId, Accepted, Status = Accepted ? Active : Inactive, LastActivityAt = now }→200.Accepted = falseсигнализирует клиенту переоткрыть просмотр (track-property-object-view).
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (heartbeat не эмитит; property-object-view-ended публикует worker activity-closer при закрытии) |
| External calls | none |
| Cache / projections / search | none (PropertyObjectViewStats инкрементирует только консюмер property-object-view-ended, property-object-view-stats.md) |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | PropertyObjectViewId не Guid / пустой | «Проверьте идентификатор просмотра» | no retry |
USER_ACTIVITY_ANONYMOUS_ID_REQUIRED | 400 | нет ни 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-просмотр, WhenPUT /property-object-views/{id}/heartbeat, Then200,Accepted = true,Status = Active,LastActivityAtпродлён; событий нет. - Given просмотр уже
Inactive(закрыт worker-ом), When heartbeat, Then200,Accepted = false,Status = Inactive; клиент переоткрывает черезtrack-property-object-view. - Given
PropertyObjectViewIdпринадлежит другому актору или не существует, When heartbeat, Then200,Accepted = false(privacy-preserving: наличие/владение чужой записи не раскрывается). - Given отсутствует и
X-Anonymous-Id, и JWTsub, When heartbeat, ThenUSER_ACTIVITY_ANONYMOUS_ID_REQUIRED(400). - Given
PropertyObjectViewId = Guid.Empty/неGuid, When heartbeat, ThenVALIDATION_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; dashboardpin-user-activity; runbook — лагactivity-closer(открытыеActiveсLastActivityAtстарше 2× порога).
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-ACT-002. Трекинг длительности и числа просмотров объекта недвижимости
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: 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, outboxproperty-object-view-ended), ../events/property-object-view-ended.md. - Контекст: ../README.md; решения ADR-0052 (вне tenant), ADR-0054 (модуль
views), ADR-0056 п.3 (heartbeat/polling-close без Redis).