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

GET /api/internal/user-activity/users/{userId}/property-object-activity — Активность инвестора по объектам недвижимости (карточка лида CRM)

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

Назначение

Дать агенту/менеджеру в карточке лида (/crm/clients/[clientId]) понимание интереса инвестора: какие объекты недвижимости и как активно он смотрел. Owning-контекст — user-activity, модуль views, feature group views. Вызывает crm по m2m internal API; record-level авторизацию (активный лид агента на этого инвестора) делает CRM — этот метод доверяет userId из маршрута в m2m-контексте. Данные ограничены retention сырых property_object_views (12 мес, ADR-0056).

Metadata

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

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

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

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

HTTP-контракт

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

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdroute {userId}GuidДа!= Guid.Empty; существование в identity не проверяется (чужой контекст)record-level по владельцу; авторизацию (активный лид) делает crm
PagingqueryPagingQueryНетPage >= 1; PageSize ∈ 1..100 (default 20)

Сортировка выдачи — server-side OrderByMethod<UserPropertyObjectActivityItem>: LastViewedAt desc, тай-брейк ViewsCount desc (недавно и активно смотренные объекты недвижимости — вверху). Клиентские сортировки/timestamp не принимаются.

Модель ответа — страница UserPropertyObjectActivityItem

Ответ — страница (Items, TotalCount, Page, PageSize); TotalCount = число различных объектов недвижимости в истории инвестора. Элемент:

UserPropertyObjectActivityItem

ПолеТипОбязательностьИсточник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) по группеМомент последнего открытия объекта недвижимости инвестором.

Открытые (Active) просмотры в агрегат не входят: их Duration ещё null (заполняется worker-ом при закрытии). Это консистентно с PropertyObjectViewStats, который инкрементируется только по property-object-view-ended (INV-LVS-002); лаг видимости — до цикла polling-worker-а (10–15 мин, ADR-0056).

Алгоритм

  1. Контекст и доступ. Вызывает crm (m2m internal); требуется scope internal и permission user-activity.activity.read-internal. Запрос вне tenant (ADR-0052 п.2); record-level авторизацию (активный лид агента на этого инвестора) выполняет вызывающий crm — здесь userId из маршрута доверенный.
  2. Проверка входа. UserId != Guid.Empty; Page >= 1, PageSize ∈ 1..100 — иначе VALIDATION_FAILED (400). Существование пользователя не проверяется (identity — чужой контекст): нет активности → пустая страница, не 404. Целевого агрегата со статусом нет (read-only).
  3. Что читаем.
    • идентификаторы сессий инвестора по UserId (правило чтения истории, property-object-view.md): так гостевые просмотры до входа, попавшие в позже связанную сессию, входят в историю без backfill UserId;
    • агрегация просмотров на чтении: по закрытым (Inactive) PropertyObjectView, принадлежащим инвестору напрямую (UserId) или через его сессии (SessionId), с группировкой по PropertyObjectId; для каждой группы — число просмотров, суммарная и (вычисляемая) средняя длительность, последнее открытие; сортировка LastViewedAt desc, тай-брейк ViewsCount desc, пагинация.
  4. Расчёт и маппинг. Переходов состояний нет (read-only). Маппинг поле-в-поле — по таблице ответа; AvgDurationSeconds = ViewsCount == 0 ? 0 : TotalDurationSeconds / ViewsCount (derived, не хранится — аналог INV-LVS-005). Открытые (Active) просмотры в агрегат не входят: их Duration ещё null (заполняется worker-ом при закрытии), консистентно с PropertyObjectViewStats (INV-LVS-002).
  5. Результат. Страница (Items, TotalCount, Page, PageSize), TotalCount = число различных объектов недвижимости в истории инвестора → 200. Нет активности / несуществующий userId200 с пустым списком (не 404). Freshness eventual: просмотры видны после закрытия worker-ом (лаг polling 10–15 мин, ADR-0056); горизонт данных — retention 12 мес.

Диаграмма

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

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

Ошибки и failure modes

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

404 не возвращается: отсутствие активности — валидный пустой результат (200, Items = []).

Acceptance-критерии

  • Given инвестор с закрытыми просмотрами по нескольким объектам, When GET /users/{userId}/property-object-activity, Then 200, страница агрегатов per-(user, объект), сортировка LastViewedAt desc, тай-брейк ViewsCount desc; TotalCount = число различных объектов.
  • Given просмотры, попавшие в позже связанную сессию (гостевые до входа), When GET, Then они входят в историю по правилу «UserId + сессии пользователя» без backfill UserId.
  • Given объект с только открытыми (Active) просмотрами, When GET, Then он не попадает в агрегат (их Duration ещё null) — виден после закрытия worker-ом (лаг polling-цикла), консистентно с PropertyObjectViewStats (INV-LVS-002).
  • Given группа объекта, When GET, Then AvgDurationSeconds = TotalDurationSeconds / ViewsCount0 при ViewsCount = 0), вычислено на чтении, не хранится.
  • Given неизвестный userId, When GET, Then 200, Items = [] (не 404).
  • Given paging вне границ (page < 1 или pageSize вне 1..100), When GET, Then VALIDATION_FAILED (400).
  • Given вызывающий без permission user-activity.activity.read-internal, When GET, Then FORBIDDEN (403); без service-токена — UNAUTHORIZED (401).
  • Given данные старше retention (12 мес), When GET, Then они отсутствуют — долгосрочный горизонт живёт в dev-stats (ADR-0057).

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

  • Новые optional-поля UserPropertyObjectActivityItem — additive, без breaking change; удаление/переименование/смена типа поля — breaking (versioned internal-контракт + согласованный rollout с crm). Unknown enum в ответе отсутствует (все поля скалярные).
  • Freshness eventual: закрытые просмотры видны после polling-цикла worker-а (ADR-0056); горизонт — retention 12 мес (старше живёт в dev-stats, ADR-0057).
  • Logs: user-activity internal property-object-activity read (actor service, targetUserId, число объектов недвижимости в ответе — без PII); metrics: user_activity_internal_property_object_activity_requests_total{result}, latency p95 < 1 с (SLO internal-списков, README); 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-методы B3: get-user-action-timeline (лента действий лида), get-property-object-view-stats (PropertyObjectViewStats для catalog) — ../README.md, раздел API.
  • Обзор: ../README.md; поток «ACT → CRM» — service-boundaries.md.
  • Решения: ADR-0052 (вне tenant), ADR-0054 (модуль views), ADR-0056 (polling-worker, retention), ADR-0057 (dev-stats).