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

POST /api/user-activity/property-object-views — Открыть просмотр объекта недвижимости

Публичная self-scope команда трекинга: открытие карточки объекта недвижимости фиксируется записью PropertyObjectView со статусом Active, серверным CreatedAt («StartedAt») и контекстом источника (каталог/подборка/реф/прямая ссылка). Endpoint ensure-ит активную Session устройства (INV-LV-002/INV-SES-003) и той же транзакцией создаёт ViewPropertyObjectActionEvent (action-event.md) — факт просмотра попадает в ленту действий. Соответствует public-ручке track-property-object-view из README. Закрытие просмотра и расчёт Duration — не здесь, а фоновый worker activity-closer (ADR-0056 п.3).

Назначение

Принять с витрины (карточка объекта недвижимости /lots/[id]) факт открытия карточки: создать Active-просмотр с привязкой к активной сессии, источником открытия и иммутабельным снимком применённого фильтра, чтобы измерить длительность просмотра и построить связь «объекты, просмотренные по фильтру». Поля агрегата — дом property-object-view.md (здесь только проекция входа/выхода и маппинг).

Metadata

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

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

ПроверкаЗначение
Actor typeAnonymous (гость по заголовку X-Anonymous-Id) или User (JWT sub; SessionId актора — id аутентификационной сессии, не путать с Session контекста)
Auth policy / Permissionsnone (публичный трекинг); rate limit по AnonymousId + IP-hash
Scope (RBAC)не применяется — актор вне tenant (ADR-0052 п.2)
Record-levelself: владелец AnonymousId/UserId; запись возможна только в собственную Session/PropertyObjectView
Tenant isolationне применяется — контекст вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются
Auditcreate (PropertyObjectView + ViewPropertyObjectActionEvent); PII нет — Device.IpAddress маскируется в логах и исключён из событий

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/user-activity/property-object-views
Success status201 (создан новый просмотр) / 200 (идемпотентный повтор — heartbeat открытого просмотра, INV-LV-001)
Idempotency headerне применяется — бизнес-дедуп по паре (SessionId, PropertyObjectId) среди Active (шаг 3)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
AnonymousIdheader X-Anonymous-IdGuidДа для гостя (для User — опционально; выдаётся start-session)формат Guid; owner-контекст записине PII (device-id); в логах допустим
ActorUserIdidentity claims (JWT sub)Guid?Нетиз токена, не из body (правило SDK)
PropertyObjectIdbodyGuidДа!= Guid.Empty (INV-LV-006); существование объекта недвижимости не проверяется синхронно
ViewSourcebodyViewSource (enum строкой)Нет (default Unknown)Catalog/Search/Selection/Favorites/News/DirectLink/Unknown; недоверенное значение вне enum → Unknown (не ошибка)недоверенный вход
UserAgentheader User-Agentstring?Нет<= 1024, обрезается; используется только при ensure новой Session (снимок DeviceInfo, дом session.md)UA — Internal

Клиентские timestamp-ы (CreatedAt/StartedAt) в контракт не входят — время ставит сервер (now(), INV-SES-004). SessionId в body не принимается — активная сессия резолвится server-side (шаг 3).

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

ПолеТипОбязательностьИсточникMaskingОписание
PropertyObjectViewIdGuidДаPropertyObjectView.IdИдентификатор записи просмотра (для heartbeat-ов карточки).
SessionIdGuidДаSession.Id (ensure)Активная сессия, в которой открыт объект недвижимости.
AnonymousIdGuidДаSession.AnonymousIdУстройство-владелец (эхо для гостя).
StatusPropertyObjectViewStatus (enum строкой)ДаPropertyObjectView.StatusВсегда Active при успехе.
StartedAtDateTimeOffsetДаPropertyObjectView.CreatedAt (серверное now())Момент открытия карточки.
IsNewboolДаcomputed (шаг 4)true — создан новый просмотр (201); false — идемпотентный heartbeat открытого (200).

Duration/EndedAt наружу не возвращаются — известны только после закрытия worker-ом (дом property-object-view.md).

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

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

  • Определён актор (X-Anonymous-Id или JWT sub); иначе USER_ACTIVITY_UNAUTHENTICATED (401).
  • Клиент открыл карточку объекта недвижимости на витрине; наличие активной сессии не требуется (обеспечивается методом).

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

  • Существует Active-сессия устройства (ensure) и не более одного Active-просмотра на пару (сессия, объект недвижимости) (INV-LV-001): создан новый (IsNew = true, 201) либо продлён открытый (IsNew = false, 200).
  • При создании — в одном согласованном изменении записан ViewPropertyObjectActionEvent (лента действий) и опубликован action-tracked.
  • Duration/EndedAt не заполнены — их проставит activity-closer при закрытии; PropertyObjectViewStats в этот момент не меняется (инкремент — по property-object-view-ended).

Алгоритм

  1. Контекст и доступ. Актор — Anonymous (X-Anonymous-Id) или User (JWT sub), self-scope, вне tenant (ADR-0052 п.2). Ни X-Anonymous-Id, ни JWT → USER_ACTIVITY_UNAUTHENTICATED (401). Дедуп — бизнесовый (шаг 3), Idempotency-Key не читается.
  2. Проверка входа. PropertyObjectId != Guid.Empty (INV-LV-006) — иначе VALIDATION_FAILED (400). ViewSource парсится в enum (Catalog/Search/Selection/Favorites/News/DirectLink/Unknown); значение вне множества → Unknown (не ошибка). Существование объекта недвижимости синхронно не проверяется (нет вызова в catalog, INV-LV-006); существование сессии не требуется — активная сессия обеспечивается (шаг 3, INV-LV-002). Клиентские timestamp (CreatedAt/StartedAt) не принимаются (INV-SES-004).
  3. Что читаем и обеспечиваем.
    • активная сессия устройства/пользователя (INV-SES-003): по AnonymousId (или UserId при User без заголовка); если её нет — создаётся новая Active-сессия со снимком DeviceInfo из User-Agent (INV-SES-001);
    • дедуп открытого просмотра (INV-LV-001): открытый (Active) просмотр той же сессии на тот же PropertyObjectId;
    • последний применённый фильтр сессии (ApplyFilter) — источник ApplyFilterEventId/FilterSnapshot (может отсутствовать → null).
  4. Бизнес-правила и переходы.
    • открытый просмотр найден → heartbeat-ветка: LastActivityAt = now(), IsNew = false (идемпотентный успех, 200);
    • иначе создаётся новый PropertyObjectView (Status = Active, CreatedAt = LastActivityAt = now()) — маппинг поле-в-поле (дом property-object-view.md): SessionId, AnonymousId/UserId (снимок из сессии), PropertyObjectId, ViewSource, ApplyFilterEventId/FilterSnapshot (AppliedFilterModel, INV-LV-005 immutable, или null); IsNew = true. Одновременно в ленту действий пишется ViewPropertyObjectActionEvent (INV-ACT-002/003, SourceMessageId = null).
  5. Что меняется. Новая сессия (если создана), новый PropertyObjectView (или heartbeat существующего), запись ViewPropertyObjectActionEvent. Гонка двойного open — partial-unique ux_property_object_views_active_session_property_objectUSER_ACTIVITY_LOT_VIEW_CONFLICT (409, перечитка → heartbeat-ветка).
  6. Какие события фиксируются. Только для новой записи — ActionTrackedKafkaEventpin.user-activity.action-tracked.v1 (ContractVersion = 1, AggregateType = "ActionEvent", payload {ActionEventId, ActionType = "ViewPropertyObject", SessionId, AnonymousId, UserId?, OccurredAt, PropertyObjectId, PropertyObjectViewId}, PII нет). heartbeat-ветка событий не публикует; property-object-view-ended публикует worker при закрытии. Доменные события PropertyObjectViewStartedEvent (порождает ViewPropertyObjectActionEvent) и ActionRecordedEvent — внутренние.
  7. Согласованность. Сессия (если новая), PropertyObjectView (создание/heartbeat), ViewPropertyObjectActionEvent и публикация action-tracked фиксируются в одном согласованном изменении.
  8. Результат. { PropertyObjectViewId, SessionId, AnonymousId, Status = Active, StartedAt = CreatedAt, IsNew }201 (новый) / 200 (heartbeat).

Диаграмма

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

ТипДетали
Integration eventsActionTrackedKafkaEventpin.user-activity.action-tracked.v1 (только новая запись; ViewPropertyObject), outbox
External callsnone — PropertyObjectId синхронно не валидируется (INV-LV-006), identity/catalog не вызываются
Cache / projections / searchnone (GIN по filter_snapshot не создаётся, ADR-0056); PropertyObjectViewStats инкрементируется позже — консюмером property-object-view-ended при закрытии, не здесь
Notificationsnone (опосредованно: crm — консюмер action-tracked для ленты карточки лида)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400PropertyObjectId == Guid.Empty / не Guid«Проверьте корректность запроса»no retry
USER_ACTIVITY_UNAUTHENTICATED401нет ни X-Anonymous-Id, ни JWT sub (owner не определён)«Не удалось определить сессию»no retry (сначала start-session)
USER_ACTIVITY_LOT_VIEW_CONFLICT409гонка INV-LV-001 (нарушение ux_property_object_views_active_session_property_object)«Повторите попытку»retry (перечитка → heartbeat-ветка)

Отсутствие/закрытость сессии ошибкой не является — активная сессия ensure-ится (INV-LV-002/INV-SES-003). Несуществующий PropertyObjectId ошибкой не является (INV-LV-006) — битые id отфильтрует выдача каталога/dev-stats.

Acceptance-критерии

  • Given актор и объект недвижимости, открытого просмотра этой пары нет, When POST /property-object-views, Then 201, Active-просмотр, IsNew = true; записан ViewPropertyObject-action; опубликован action-tracked с PropertyObjectViewId/PropertyObjectId.
  • Given открытый просмотр той же пары (сессия, объект) уже есть, When повторный POST, Then 200, IsNew = false, продлён LastActivityAt; новый просмотр и событие не создаются (INV-LV-001).
  • Given активной сессии устройства нет, When POST, Then сессия создаётся (ensure), просмотр открывается — не ошибка (INV-LV-002/INV-SES-003).
  • Given ViewSource вне enum, When POST, Then значение приведено к Unknown, 201 (не ошибка).
  • Given PropertyObjectId = Guid.Empty/не Guid, When POST, Then VALIDATION_FAILED (400).
  • Given PropertyObjectId, которого нет в каталоге, When POST, Then 201 — существование синхронно не проверяется (INV-LV-006).
  • Given гонка двойного открытия одной пары, When конкурентно, Then один 201, второй USER_ACTIVITY_LOT_VIEW_CONFLICT (409) → перечитка переводит в heartbeat-ветку (200).
  • Given нет ни X-Anonymous-Id, ни JWT sub, When POST, Then USER_ACTIVITY_UNAUTHENTICATED (401).
  • Given клиент прислал CreatedAt/StartedAt, When POST, Then он игнорируется, StartedAt — серверный (INV-SES-004).

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

  • Новые optional-поля запроса/ответа и новые значения ViewSource (unknown → Unknown) — additive, без breaking change; удаление/переименование/смена типа поля — breaking (migration + rollout). Контракт AppliedFilterModel в FilterSnapshot версионируется ContractVersion (дом action-event.md).
  • Logs: property-object-view opened / property-object-view heartbeat (PropertyObjectViewId, PropertyObjectId, SessionId, AnonymousId; Device.IpAddress — маской /24, в событие не идёт); metrics: user_activity_property_object_views_total{result=new|heartbeat}, latency p95 < 200 мс (heartbeat-ветка — условное продление без загрузки агрегата, README SLO); traces — сквозной trace через traceparent; dashboard Grafana pin-user-activity; runbook — docs/06-services/user-activity/runbooks/ (будущий путь по конвенции).

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

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

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

  • Сущности: property-object-view.md (дом PropertyObjectView, INV-LV-001..008), session.md (ensure-логика, DeviceInfo), action-event.md (ViewPropertyObjectActionEvent, дом AppliedFilterModel), property-object-view-stats.md (инкремент при закрытии).
  • Событие: ../events/action-tracked.md (топик pin.user-activity.action-tracked.v1; реконсиляция имени action-recorded → action-tracked — README решение №4); закрытие/Duration../background/activity-closer.md.
  • Смежные ручки: heartbeat, link, track-action, get-my-history (карта — README).
  • Решения: ADR-0052 (вне tenant), ADR-0054 (модуль views), ADR-0056 п.2–3 (simplicity-first, механизм закрытия).