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

GET /api/internal/user-activity/users/{userId}/property-objects/view-stats — Батч просмотров инвестора по объектам недвижимости (обогащение карточек CRM)

Internal m2m-точка чтения статистики просмотров конкретного инвестора по явному списку объектов недвижимости одним запросом. По каждому PropertyObjectId из списка отдаёт срез per-(user, property-object): «сколько раз / суммарно и в среднем сколько смотрел / когда смотрел последний раз». Агрегация считается на чтении по просмотрам PropertyObjectView с правилом чтения истории «UserId + сессии пользователя» (property-object-view.md, «Правило чтения истории»).

Это батч по списку id (аналог платформенного get-property-object-view-stats.md), но срез — per-(user, property-object), а не платформенный. От paged-ленты get-user-property-object-activity.md отличается формой: там открытая страница всей истории инвестора, здесь — ограниченная выборка по явному набору PropertyObjectId видимой страницы каталога. Предназначен для композиционного обогащения карточек объектов в аутентифицированном agent/CRM-слое: каталог остаётся платформенной проекцией без персонализации (ADR-0056, Часть D), а «в контексте инвестора» CRM пакетно добирает просмотры по id объектов видимой страницы.

Назначение

Отдать вызывающему (crm) статистику просмотров инвестора по набору объектов недвижимости одним запросом: для каждого известного PropertyObjectIdViewsCount, TotalDurationSeconds, вычисляемая AvgDurationSeconds, LastViewedAt. Owning-контекст — user-activity, модуль views, feature group views. Record-level авторизацию (активный лид агента на этого инвестора) делает CRM — метод доверяет userId из маршрута в m2m-контексте (delegated authorization, паттерн UC-ACT-004). Данные ограничены retention сырых property_object_views (12 мес, ADR-0056); долгосрочный горизонт — dev-stats (ADR-0057).

Metadata

ПолеЗначение
Сервис/контекстuser-activity
API areainternal (m2m; вызывающий — crm)
Feature groupviews
Статусdraft
Документdocs/06-services/user-activity/api/internal/get-user-property-object-view-stats-batch.md

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

ПроверкаЗначение
Actor typeExternalSystem — сервис crm (m2m internal-gateway + service auth)
Auth policy / Permissionsm2m internal (service token / gateway mTLS); scope internal; permission user-activity.activity.read-internal (дом — identity/domain/permission.md)
Scope (RBAC)не применяется (актор вне tenant; user-activity вне tenant, ADR-0052 п.2)
Record-levelизоляция по UserId из маршрута; проверку прав на этот UserId (активный лид инвестора у агента) выполняет вызывающий crm — delegated authorization (UC-ACT-004)
Tenant isolationне применяется (вне tenant, ADR-0052 п.2; tenant-интерсепторы не подключаются)
Auditread-sensitive: чтение чужой истории по id инвестора — access-лог actor(service) + targetUserId; PII в ответе нет (только id и длительности)

Self-scope по актору здесь не применяется: userId приходит маршрутом (доверенный m2m-вызов), а не из JWT actor-контекста, как в public self-scope методах.

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/internal/user-activity/users/{userId}/property-objects/view-stats
Success status200 (в т.ч. пустой items — инвестор не смотрел ни один из запрошенных объектов)
Idempotency headerне применяется (safe GET)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdroute {userId}GuidДа!= Guid.Empty; существование в identity не проверяется (чужой контекст)record-level по владельцу; авторизацию (активный лид) делает crm
PropertyObjectIdsquery (csv propertyObjectIds=g1,g2,...)IReadOnlyList<Guid>Да1..200 элементов после дедупликации; каждый != Guid.Empty; дубликаты схлопываются; иначе VALIDATION_FAILEDнедоверенный: несуществующие/непросмотренные PropertyObjectId не ошибка — просто отсутствуют в выдаче (шаг 3)

PropertyObjectIds бьётся batch-ем под лимит URL: 200 Guid ≈ 7.4 КБ query-string (в пределах). Клиентские timestamp-ы/фильтры по времени и сортировки не принимаются — граница выборки задана явным списком id, а не пагинацией. Обогащение композиционно: CRM подаёт id объектов видимой страницы каталога.

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

Ответ — ограниченный батч по явному списку id, а не открытый список: границу задаёт валидированный размер входа (1..200), пагинация неприменима. Открытые ленты возвращаются страницами (get-user-property-object-activity.md).

ПолеТипОбязательностьИсточникMaskingОписание
itemsIReadOnlyList<UserPropertyObjectViewStatsModel>Данайденные группы по уникальному списку PropertyObjectId для данного UserIdТолько те объекты, которые инвестор просматривал хотя бы раз (закрытые просмотры).
requestedCountintДаcomputedЧисло уникальных запрошенных PropertyObjectId.
foundCountintДаitems.CountСколько объектов найдено (requested - found — объекты без единого закрытого просмотра этим инвестором).
asOfDateTimeOffsetДаserver now()Момент формирования ответа (маркер свежести, серверное время).

UserPropertyObjectViewStatsModel (срез per-(user, property-object) — дом полей property-object-view.md)

ПолеТипОбязательностьИсточникMaskingОписание
propertyObjectIdGuidДаproperty_object_views.property_object_id (ключ группировки)Объект недвижимости. Display-данные (адрес/цена/фото) добирает crm батчем в catalog; существование не гарантируется (INV-LV-006).
viewsCountlongДаcount(*) по группе (только Inactive)Сколько раз инвестор открывал карточку объекта (закрытые просмотры).
totalDurationSecondslongДаsum(duration) по группе, в секундахСуммарная длительность просмотров объекта инвестором.
avgDurationSecondslongДаcomputed: ViewsCount == 0 ? 0 : TotalDurationSeconds / ViewsCountСредняя длительность одного просмотра. Derived, не хранится (аналог INV-LVS-005).
lastViewedAtDateTimeOffsetДаmax(created_at) по группеМомент последнего открытия объекта инвестором.

Отсутствующий в items PropertyObjectId вызывающий трактует как нулевую активность (ViewsCount = 0): объект инвестор не открывал (или только Active-просмотры без закрытия). Синтетические нулевые строки не возвращаются (в выдаче — только реально существующие группы). Открытые (Active) просмотры в агрегат не входят: их Duration ещё null (заполняется worker-ом при закрытии) — консистентно с PropertyObjectViewStats (INV-LVS-002); лаг видимости — до цикла polling-worker-а (10–15 мин, ADR-0056).

Алгоритм

  1. Контекст и доступ. Вызывает crm (m2m internal); требуется scope internal и permission user-activity.activity.read-internal — иначе 403. Запрос вне tenant (ADR-0052 п.2); record-level авторизацию (активный лид агента на этого инвестора) выполняет вызывающий crm — здесь userId из маршрута доверенный (delegated authorization, UC-ACT-004).
  2. Проверка входа. UserId != Guid.Empty; PropertyObjectIds непустой, после дедупликации содержит 1..200 элементов, каждый != Guid.Empty — иначе VALIDATION_FAILED (400). Существование пользователя не проверяется (identity — чужой контекст): нет активности → пустой items, не 404. Существование объектов не проверяется: неизвестные/непросмотренные PropertyObjectId молча отсутствуют в выдаче, это не ошибка. Целевого агрегата со статусом нет (read-only).
  3. Что читаем.
    • идентификаторы сессий инвестора по UserId (правило чтения истории, property-object-view.md): так гостевые просмотры до входа, попавшие в позже связанную сессию, входят в историю без backfill UserId;
    • агрегация просмотров на чтении: по закрытым (Inactive) PropertyObjectView, принадлежащим инвестору напрямую (UserId) или через его сессии (SessionId), ограниченным уникальным списком PropertyObjectIds, с группировкой по PropertyObjectId; для каждой группы — число просмотров, суммарная и (вычисляемая) средняя длительность, последнее открытие. Возвращаются только существующие группы.
  4. Расчёт и маппинг. Переходов состояний нет (read-only). Маппинг поле-в-поле — таблица UserPropertyObjectViewStatsModel; avgDurationSeconds = ViewsCount == 0 ? 0 : TotalDurationSeconds / ViewsCount (derived, не хранится — аналог INV-LVS-005). requestedCount = число уникальных запрошенных id, foundCount = число найденных групп, asOf = серверное now(). Открытые (Active) просмотры в агрегат не входят (Duration ещё null), консистентно с PropertyObjectViewStats (INV-LVS-002).
  5. Результат. 200 с найденными группами. Консистентность eventual: закрытые просмотры видны после polling-цикла worker-а activity-closer (10–15 мин, ADR-0056 п.3), незакрытые (Active) ещё не учтены; горизонт данных — retention 12 мес. Пустой items (инвестор не смотрел ни один из запрошенных объектов или неизвестный userId) — валидный ответ 200.

Диаграмма

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

ТипДетали
Integration eventsnone (safe read)
External callsnone — display-данные объектов недвижимости crm добирает батчем в catalog вне этого метода
Cache / projections / searchопциональный read-cache (short TTL, ключ userId+набор id, privacy-scope по userId) — по умолчанию выключен, агрегат считается query-time (ADR-0056)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400UserId = Guid.Empty, либо PropertyObjectIds пустой / > 200 / содержит Guid.Empty«Проверьте параметры запроса»no retry
UNAUTHORIZED401m2m без валидного service token«Неавторизованный вызов»no retry
FORBIDDEN403актор без scope internal / permission user-activity.activity.read-internal«Доступ запрещён»no retry

Пустая выдача (items = [], foundCount = 0) при валидных, но непросмотренных/неизвестных PropertyObjectId (или неизвестном userId) — не ошибка (200). 404 не возвращается.

Acceptance-критерии

  • Given инвестор с закрытыми просмотрами по N из запрошенных объектов и M непросмотренных/неизвестных (после дедупликации N + M ≤ 200), When GET .../property-objects/view-stats, Then 200, items — только N групп per-(user, объект), requestedCount = N + M, foundCount = N.
  • Given просмотры, попавшие в позже связанную сессию (гостевые до входа), When GET, Then они входят в агрегат по правилу «UserId + сессии пользователя» без backfill UserId.
  • Given объект, который инвестор не открывал (или только Active-просмотры без закрытия), When GET, Then его нет в items; вызывающий трактует отсутствие как нулевую активность (ViewsCount = 0) — синтетическая нулевая строка не возвращается.
  • Given найденная группа, When GET, Then avgDurationSeconds = TotalDurationSeconds / ViewsCount0 при ViewsCount = 0), значение вычислено на чтении, не хранится (аналог INV-LVS-005).
  • Given UserId = Guid.Empty, либо PropertyObjectIds пуст, содержит Guid.Empty или > 200 элементов, When GET, Then VALIDATION_FAILED (400).
  • Given вызывающий без permission user-activity.activity.read-internal, When GET, Then FORBIDDEN (403); без service-токена — UNAUTHORIZED (401).
  • Given объект с только открытыми (Active, ещё не закрытыми) просмотрами инвестора, When GET, Then он не учтён в агрегате (их Duration ещё null) — виден после закрытия worker-ом (лаг polling-цикла activity-closer), консистентно с PropertyObjectViewStats (INV-LVS-002); freshness eventual.
  • Given неизвестный userId или все запрошенные объекты непросмотрены, When GET, Then 200, items = [], foundCount = 0 (не 404).
  • Given данные старше retention (12 мес), When GET, Then они отсутствуют — долгосрочный горизонт живёт в dev-stats (ADR-0057).

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

  • Новые optional-поля UserPropertyObjectViewStatsModel/результата — additive (non-breaking); удаление/ переименование/смена типа — breaking (versioned internal-контракт + скоординированный rollout с crm). Лимит PropertyObjectIds (200) — часть контракта; изменение — с уведомлением потребителя. Unknown enum в ответе отсутствует (все поля скалярные).
  • Freshness eventual: закрытые просмотры видны после polling-цикла worker-а activity-closer (ADR-0056); горизонт — retention 12 мес (старше живёт в dev-stats, ADR-0057).
  • Logs: user-activity internal user-property-object-view-stats-batch requested (actor service, targetUserId, requestedCount, foundCount — сами id объектов не пишутся; без PII); metrics user_activity_internal_user_property_object_view_stats_batch_total{result}, histogram размера батча, latency p95 < 1 с (SLO internal-списков, README.md); traces — сквозной trace через traceparent; dashboard pin-user-activity; runbook — docs/06-services/user-activity/runbooks/ (будущий путь по конвенции).

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

  • Сущности: property-object-view.md (дом полей, «Правило чтения истории»), property-object-view-stats.md (разграничение per-property-object vs per-(user, property-object)), session.md (связывание сессий с UserId).
  • Соседние internal-методы: get-user-property-object-activity.md (paged-лента всей истории инвестора — та же агрегация, другая форма выдачи), get-property-object-view-stats.md (платформенный батч PropertyObjectViewStats для catalog) — карта API: ../README.md.
  • Композиционное обогащение и делегированная авторизация по активному лиду: паттерн UC-ACT-004; поток «ACT → CRM» — service-boundaries.md.
  • Решения: ADR-0052 (вне tenant), ADR-0054 (модуль views), ADR-0056 (polling-worker, retention, каталог без персонализации — Часть D), ADR-0057 (dev-stats).