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) статистику просмотров инвестора по набору объектов недвижимости одним запросом:
для каждого известного PropertyObjectId — ViewsCount, 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 area | internal (m2m; вызывающий — crm) |
| Feature group | views |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/internal/get-user-property-object-view-stats-batch.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | ExternalSystem — сервис crm (m2m internal-gateway + service auth) |
| Auth policy / Permissions | m2m 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-интерсепторы не подключаются) |
| Audit | read-sensitive: чтение чужой истории по id инвестора — access-лог actor(service) + targetUserId; PII в ответе нет (только id и длительности) |
Self-scope по актору здесь не применяется: userId приходит маршрутом (доверенный m2m-вызов), а не из
JWT actor-контекста, как в public self-scope методах.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | GET |
| Route | /api/internal/user-activity/users/{userId}/property-objects/view-stats |
| Success status | 200 (в т.ч. пустой items — инвестор не смотрел ни один из запрошенных объектов) |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserId | route {userId} | Guid | Да | != Guid.Empty; существование в identity не проверяется (чужой контекст) | record-level по владельцу; авторизацию (активный лид) делает crm |
PropertyObjectIds | query (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 | Описание |
|---|---|---|---|---|---|
items | IReadOnlyList<UserPropertyObjectViewStatsModel> | Да | найденные группы по уникальному списку PropertyObjectId для данного UserId | — | Только те объекты, которые инвестор просматривал хотя бы раз (закрытые просмотры). |
requestedCount | int | Да | computed | — | Число уникальных запрошенных PropertyObjectId. |
foundCount | int | Да | items.Count | — | Сколько объектов найдено (requested - found — объекты без единого закрытого просмотра этим инвестором). |
asOf | DateTimeOffset | Да | server now() | — | Момент формирования ответа (маркер свежести, серверное время). |
UserPropertyObjectViewStatsModel (срез per-(user, property-object) — дом полей property-object-view.md)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
propertyObjectId | Guid | Да | property_object_views.property_object_id (ключ группировки) | — | Объект недвижимости. Display-данные (адрес/цена/фото) добирает crm батчем в catalog; существование не гарантируется (INV-LV-006). |
viewsCount | long | Да | count(*) по группе (только Inactive) | — | Сколько раз инвестор открывал карточку объекта (закрытые просмотры). |
totalDurationSeconds | long | Да | sum(duration) по группе, в секундах | — | Суммарная длительность просмотров объекта инвестором. |
avgDurationSeconds | long | Да | computed: ViewsCount == 0 ? 0 : TotalDurationSeconds / ViewsCount | — | Средняя длительность одного просмотра. Derived, не хранится (аналог INV-LVS-005). |
lastViewedAt | DateTimeOffset | Да | max(created_at) по группе | — | Момент последнего открытия объекта инвестором. |
Отсутствующий в items PropertyObjectId вызывающий трактует как нулевую активность (ViewsCount = 0):
объект инвестор не открывал (или только Active-просмотры без закрытия). Синтетические нулевые строки не
возвращаются (в выдаче — только реально существующие группы). Открытые (Active) просмотры в агрегат не
входят: их Duration ещё null (заполняется worker-ом при закрытии) — консистентно с
PropertyObjectViewStats (INV-LVS-002); лаг видимости — до цикла polling-worker-а (10–15 мин, ADR-0056).
Алгоритм
- Контекст и доступ. Вызывает
crm(m2m internal); требуется scopeinternalи permissionuser-activity.activity.read-internal— иначе403. Запрос вне tenant (ADR-0052 п.2); record-level авторизацию (активный лид агента на этого инвестора) выполняет вызывающийcrm— здесьuserIdиз маршрута доверенный (delegated authorization, UC-ACT-004). - Проверка входа.
UserId != Guid.Empty;PropertyObjectIdsнепустой, после дедупликации содержит1..200элементов, каждый!= Guid.Empty— иначеVALIDATION_FAILED(400). Существование пользователя не проверяется (identity — чужой контекст): нет активности → пустойitems, не404. Существование объектов не проверяется: неизвестные/непросмотренныеPropertyObjectIdмолча отсутствуют в выдаче, это не ошибка. Целевого агрегата со статусом нет (read-only). - Что читаем.
- идентификаторы сессий инвестора по
UserId(правило чтения истории, property-object-view.md): так гостевые просмотры до входа, попавшие в позже связанную сессию, входят в историю без backfillUserId; - агрегация просмотров на чтении: по закрытым (
Inactive)PropertyObjectView, принадлежащим инвестору напрямую (UserId) или через его сессии (SessionId), ограниченным уникальным спискомPropertyObjectIds, с группировкой поPropertyObjectId; для каждой группы — число просмотров, суммарная и (вычисляемая) средняя длительность, последнее открытие. Возвращаются только существующие группы.
- идентификаторы сессий инвестора по
- Расчёт и маппинг. Переходов состояний нет (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). - Результат.
200с найденными группами. Консистентность eventual: закрытые просмотры видны после polling-цикла worker-аactivity-closer(10–15 мин, ADR-0056 п.3), незакрытые (Active) ещё не учтены; горизонт данных — retention 12 мес. Пустойitems(инвестор не смотрел ни один из запрошенных объектов или неизвестныйuserId) — валидный ответ200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (safe read) |
| External calls | none — display-данные объектов недвижимости crm добирает батчем в catalog вне этого метода |
| Cache / projections / search | опциональный read-cache (short TTL, ключ userId+набор id, privacy-scope по userId) — по умолчанию выключен, агрегат считается query-time (ADR-0056) |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | UserId = Guid.Empty, либо PropertyObjectIds пустой / > 200 / содержит Guid.Empty | «Проверьте параметры запроса» | no retry |
UNAUTHORIZED | 401 | m2m без валидного service token | «Неавторизованный вызов» | no retry |
FORBIDDEN | 403 | актор без scope internal / permission user-activity.activity.read-internal | «Доступ запрещён» | no retry |
Пустая выдача (items = [], foundCount = 0) при валидных, но непросмотренных/неизвестных
PropertyObjectId (или неизвестном userId) — не ошибка (200). 404 не возвращается.
Acceptance-критерии
- Given инвестор с закрытыми просмотрами по N из запрошенных объектов и M непросмотренных/неизвестных
(после дедупликации
N + M ≤ 200), WhenGET .../property-objects/view-stats, Then200,items— только N групп per-(user, объект),requestedCount = N + M,foundCount = N. - Given просмотры, попавшие в позже связанную сессию (гостевые до входа), When
GET, Then они входят в агрегат по правилу «UserId+ сессии пользователя» без backfillUserId. - Given объект, который инвестор не открывал (или только
Active-просмотры без закрытия), WhenGET, Then его нет вitems; вызывающий трактует отсутствие как нулевую активность (ViewsCount = 0) — синтетическая нулевая строка не возвращается. - Given найденная группа, When
GET, ThenavgDurationSeconds = TotalDurationSeconds / ViewsCount(и0приViewsCount = 0), значение вычислено на чтении, не хранится (аналог INV-LVS-005). - Given
UserId = Guid.Empty, либоPropertyObjectIdsпуст, содержитGuid.Emptyили > 200 элементов, WhenGET, ThenVALIDATION_FAILED(400). - Given вызывающий без permission
user-activity.activity.read-internal, WhenGET, ThenFORBIDDEN(403); без service-токена —UNAUTHORIZED(401). - Given объект с только открытыми (
Active, ещё не закрытыми) просмотрами инвестора, WhenGET, Then он не учтён в агрегате (ихDurationещёnull) — виден после закрытия worker-ом (лаг polling-циклаactivity-closer), консистентно сPropertyObjectViewStats(INV-LVS-002); freshness eventual. - Given неизвестный
userIdили все запрошенные объекты непросмотрены, WhenGET, Then200,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(actorservice,targetUserId,requestedCount,foundCount— сами id объектов не пишутся; без PII); metricsuser_activity_internal_user_property_object_view_stats_batch_total{result}, histogram размера батча, latency p95 < 1 с (SLO internal-списков, README.md); traces — сквозной trace черезtraceparent; dashboardpin-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).