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 area | public (self-scope: гость по X-Anonymous-Id, пользователь по JWT sub) |
| Feature group | views |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/public/start-property-object-view.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Anonymous (гость по заголовку X-Anonymous-Id) или User (JWT sub; SessionId актора — id аутентификационной сессии, не путать с Session контекста) |
| Auth policy / Permissions | none (публичный трекинг); rate limit по AnonymousId + IP-hash |
| Scope (RBAC) | не применяется — актор вне tenant (ADR-0052 п.2) |
| Record-level | self: владелец AnonymousId/UserId; запись возможна только в собственную Session/PropertyObjectView |
| Tenant isolation | не применяется — контекст вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются |
| Audit | create (PropertyObjectView + ViewPropertyObjectActionEvent); PII нет — Device.IpAddress маскируется в логах и исключён из событий |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/user-activity/property-object-views |
| Success status | 201 (создан новый просмотр) / 200 (идемпотентный повтор — heartbeat открытого просмотра, INV-LV-001) |
| Idempotency header | не применяется — бизнес-дедуп по паре (SessionId, PropertyObjectId) среди Active (шаг 3) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
AnonymousId | header X-Anonymous-Id | Guid | Да для гостя (для User — опционально; выдаётся start-session) | формат Guid; owner-контекст записи | не PII (device-id); в логах допустим |
ActorUserId | identity claims (JWT sub) | Guid? | Нет | из токена, не из body (правило SDK) | — |
PropertyObjectId | body | Guid | Да | != Guid.Empty (INV-LV-006); существование объекта недвижимости не проверяется синхронно | — |
ViewSource | body | ViewSource (enum строкой) | Нет (default Unknown) | Catalog/Search/Selection/Favorites/News/DirectLink/Unknown; недоверенное значение вне enum → Unknown (не ошибка) | недоверенный вход |
UserAgent | header User-Agent | string? | Нет | <= 1024, обрезается; используется только при ensure новой Session (снимок DeviceInfo, дом session.md) | UA — Internal |
Клиентские timestamp-ы (CreatedAt/StartedAt) в контракт не входят — время ставит сервер
(now(), INV-SES-004). SessionId в body не принимается — активная сессия резолвится server-side (шаг 3).
Модель ответа StartPropertyObjectViewResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
PropertyObjectViewId | Guid | Да | PropertyObjectView.Id | — | Идентификатор записи просмотра (для heartbeat-ов карточки). |
SessionId | Guid | Да | Session.Id (ensure) | — | Активная сессия, в которой открыт объект недвижимости. |
AnonymousId | Guid | Да | Session.AnonymousId | — | Устройство-владелец (эхо для гостя). |
Status | PropertyObjectViewStatus (enum строкой) | Да | PropertyObjectView.Status | — | Всегда Active при успехе. |
StartedAt | DateTimeOffset | Да | PropertyObjectView.CreatedAt (серверное now()) | — | Момент открытия карточки. |
IsNew | bool | Да | computed (шаг 4) | — | true — создан новый просмотр (201); false — идемпотентный heartbeat открытого (200). |
Duration/EndedAt наружу не возвращаются — известны только после закрытия worker-ом (дом property-object-view.md).
Предусловия и постусловия
Предусловия:
- Определён актор (
X-Anonymous-Idили JWTsub); иначе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).
Алгоритм
- Контекст и доступ. Актор —
Anonymous(X-Anonymous-Id) илиUser(JWTsub), self-scope, вне tenant (ADR-0052 п.2). НиX-Anonymous-Id, ни JWT →USER_ACTIVITY_UNAUTHENTICATED(401). Дедуп — бизнесовый (шаг 3), Idempotency-Key не читается. - Проверка входа.
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). - Что читаем и обеспечиваем.
- активная сессия устройства/пользователя (INV-SES-003): по
AnonymousId(илиUserIdприUserбез заголовка); если её нет — создаётся новаяActive-сессия со снимкомDeviceInfoизUser-Agent(INV-SES-001); - дедуп открытого просмотра (INV-LV-001): открытый (
Active) просмотр той же сессии на тот жеPropertyObjectId; - последний применённый фильтр сессии (
ApplyFilter) — источникApplyFilterEventId/FilterSnapshot(может отсутствовать →null).
- активная сессия устройства/пользователя (INV-SES-003): по
- Бизнес-правила и переходы.
- открытый просмотр найден → 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).
- открытый просмотр найден → heartbeat-ветка:
- Что меняется. Новая сессия (если создана), новый
PropertyObjectView(или heartbeat существующего), записьViewPropertyObjectActionEvent. Гонка двойного open — partial-uniqueux_property_object_views_active_session_property_object→USER_ACTIVITY_LOT_VIEW_CONFLICT(409, перечитка → heartbeat-ветка). - Какие события фиксируются. Только для новой записи —
ActionTrackedKafkaEvent→pin.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— внутренние. - Согласованность. Сессия (если новая),
PropertyObjectView(создание/heartbeat),ViewPropertyObjectActionEventи публикацияaction-trackedфиксируются в одном согласованном изменении. - Результат.
{ PropertyObjectViewId, SessionId, AnonymousId, Status = Active, StartedAt = CreatedAt, IsNew }→201(новый) /200(heartbeat).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | ActionTrackedKafkaEvent → pin.user-activity.action-tracked.v1 (только новая запись; ViewPropertyObject), outbox |
| External calls | none — PropertyObjectId синхронно не валидируется (INV-LV-006), identity/catalog не вызываются |
| Cache / projections / search | none (GIN по filter_snapshot не создаётся, ADR-0056); PropertyObjectViewStats инкрементируется позже — консюмером property-object-view-ended при закрытии, не здесь |
| Notifications | none (опосредованно: crm — консюмер action-tracked для ленты карточки лида) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | PropertyObjectId == Guid.Empty / не Guid | «Проверьте корректность запроса» | no retry |
USER_ACTIVITY_UNAUTHENTICATED | 401 | нет ни X-Anonymous-Id, ни JWT sub (owner не определён) | «Не удалось определить сессию» | no retry (сначала start-session) |
USER_ACTIVITY_LOT_VIEW_CONFLICT | 409 | гонка 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, Then201,Active-просмотр,IsNew = true; записанViewPropertyObject-action; опубликованaction-trackedсPropertyObjectViewId/PropertyObjectId. - Given открытый просмотр той же пары (сессия, объект) уже есть, When повторный
POST, Then200,IsNew = false, продлёнLastActivityAt; новый просмотр и событие не создаются (INV-LV-001). - Given активной сессии устройства нет, When
POST, Then сессия создаётся (ensure), просмотр открывается — не ошибка (INV-LV-002/INV-SES-003). - Given
ViewSourceвне enum, WhenPOST, Then значение приведено кUnknown,201(не ошибка). - Given
PropertyObjectId = Guid.Empty/неGuid, WhenPOST, ThenVALIDATION_FAILED(400). - Given
PropertyObjectId, которого нет в каталоге, WhenPOST, Then201— существование синхронно не проверяется (INV-LV-006). - Given гонка двойного открытия одной пары, When конкурентно, Then один
201, второйUSER_ACTIVITY_LOT_VIEW_CONFLICT(409) → перечитка переводит в heartbeat-ветку (200). - Given нет ни
X-Anonymous-Id, ни JWTsub, WhenPOST, ThenUSER_ACTIVITY_UNAUTHENTICATED(401). - Given клиент прислал
CreatedAt/StartedAt, WhenPOST, 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 Grafanapin-user-activity; runbook —docs/06-services/user-activity/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-ACT-002. Трекинг длительности и числа просмотров объекта недвижимости
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: 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, механизм закрытия).