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

GET /api/internal/user-activity/property-objects/view-stats — Батч платформенных счётчиков просмотров объектов недвижимости

Внутренняя m2m-точка чтения агрегата PropertyObjectViewStats батчем по списку PropertyObjectId: catalog берёт «сколько раз / как долго / когда последний раз» по объектам недвижимости видимой страницы для сортировки «Популярные» и бейджей популярности (get-property-objects.md, сортировка Recommended/популярность). Читает накопленные счётчики по списку PropertyObjectId (ключ — сам PropertyObjectId, INV-LVS-001). Это единственная read-операция user-activity над платформенными данными: агрегат по объекту недвижимости без владельца (ADR-0052 п.3), поэтому — вне self-scope X-Anonymous-Id/JWT.

Назначение

Отдать потребителю (catalog) накопленную статистику просмотров по набору объектов недвижимости одним запросом: для каждого известного PropertyObjectIdViewsCount, TotalDuration, вычисляемая AvgDuration, LastViewedAt. Owning-контекст — user-activity, модуль views, feature group views. Многомерная аналитика застройщика (даты/зрители/иерархия, 3 года) сюда не входит — это dev-stats/ClickHouse (ADR-0057).

Metadata

ПолеЗначение
Сервис/контекстuser-activity
API areainternal (m2m: catalog)
Feature groupviews
Статусdraft
Документdocs/06-services/user-activity/api/internal/get-property-object-view-stats.md

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

ПроверкаЗначение
Actor typeSystem / ExternalSystem — сервис catalog (m2m, internal-gateway + service auth)
Auth policy / Permissionsm2m internal (сеть/mTLS/service-token); permission user-activity.stats.view (дом — identity/domain/permission.md)
Scope (RBAC)платформа — PropertyObjectViewStats платформенный агрегат по объекту недвижимости (ADR-0052 п.3)
Record-levelне применяется — у агрегата нет владельца UserId/AnonymousId; изоляции по актору-зрителю нет (в отличие от Session/PropertyObjectView/ActionEvent). Заголовок X-Anonymous-Id не участвует
Tenant isolationне применяется — вне tenant (ADR-0052 п.3); request не tenanted, tenant-интерсепторы не подключаются
Auditnone (чтение платформенных счётчиков; PII нет — только id и длительности)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/internal/user-activity/property-objects/view-stats
Success status200
Idempotency headerне применяется (safe GET)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
CallerServiceservice auth (m2m identity)stringДасервис с permission user-activity.stats.view; иначе 403
PropertyObjectIdsquery (csv propertyObjectIds=g1,g2,...)IReadOnlyList<Guid>Да1..200 элементов после дедупликации; каждый != Guid.Empty; дубликаты схлопываются; иначе ValidationErrorнедоверенный: несуществующие/битые PropertyObjectId не ошибка — просто отсутствуют в выдаче (шаг 2)

PropertyObjectIds бьётся batch-ем под лимит URL: 200 Guid ≈ 7.4 КБ query-string (в пределах). Клиентские timestamp-ы/фильтры по времени не принимаются — свежесть отражает серверное поле updatedAt строки.

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

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

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

PropertyObjectViewStatsModel (проекция агрегата — дом полей property-object-view-stats.md, здесь только дельта)

ПолеТипОбязательностьИсточникMaskingОписание
propertyObjectIdGuidДаPropertyObjectViewStats.Id (= PropertyObjectId)Объект недвижимости.
viewsCountlongДаViewsCountСколько раз объект недвижимости просматривался (закрытые просмотры).
totalDurationTimeSpanДаTotalDuration (PG interval)Суммарная длительность (JSON — ISO-8601 duration).
avgDurationTimeSpanДаcomputed TotalDuration / ViewsCount (0 при ViewsCount = 0) — INV-LVS-005Средняя длительность (не хранится, считается на чтении).
lastViewedAtDateTimeOffsetДаLastViewedAtМомент окончания последнего просмотра.
updatedAtDateTimeOffsetДаUpdatedAtОбновление строки (лаг консюмера property-object-view-ended = updatedAt vs lastViewedAt).

Отсутствующий в items PropertyObjectId потребитель трактует как нулевую статистику (ViewsCount = 0): строка PropertyObjectViewStats создаётся первым событием property-object-view-ended (INV-LVS-001), никогда-не-просмотренный объект недвижимости строки не имеет. Синтетические нулевые строки не возвращаются (в выдаче — только реально существующие строки).

Алгоритм

  1. Контекст и доступ. Вызывает сервис catalog (m2m internal); требуется permission user-activity.stats.view — иначе 403. Запрос вне tenant (платформенный агрегат, ADR-0052 п.3).
  2. Проверка входа. PropertyObjectIds непустой, после дедупликации содержит 1..200 элементов, каждый != Guid.Empty — иначе VALIDATION_FAILED (400). Существование объектов не проверяется: неизвестные/битые PropertyObjectId (объект ни разу не просмотрен, строки PropertyObjectViewStats нет — INV-LVS-001) молча отсутствуют в выдаче, это не ошибка. Состояния у агрегата нет (нет Status); record-level по владельцу отсутствует (агрегат без зрителя) — единственный гейт доступа проверен в шаге 1.
  3. Что читаем. Накопленные счётчики PropertyObjectViewStats по уникальному списку PropertyObjectId (ключ — сам PropertyObjectId, INV-LVS-001). Возвращаются только существующие строки.
  4. Расчёт и маппинг. Маппинг поле-в-поле — таблица PropertyObjectViewStatsModel; avgDuration = ViewsCount > 0 ? TotalDuration / ViewsCount : 0 (INV-LVS-005 DerivedNotStored — не хранится, чтобы не рассинхронизироваться с инкрементами). requestedCount = число уникальных запрошенных id, foundCount = число найденных строк, asOf = серверное now().
  5. Результат. 200 с найденными строками. Консистентность eventual: счётчики отстают на лаг консюмера property-object-view-ended, а незакрытые (Active) просмотры ещё не учтены (закрытие — polling-worker activity-closer 10–15 мин, ADR-0056 п.3); свежесть строки — в updatedAt. Пустой items (все PropertyObjectId неизвестны) — валидный ответ 200.

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

ТипДетали
Integration eventsnone (safe read)
External callsnone — чтение накопленных счётчиков по списку PropertyObjectId
Cache / projections / searchчтение PK btree(id); собственного кэша нет (ADR-0056); бейджи популярности catalog кэширует на своей стороне
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401нет/невалиден m2m service-token«Требуется service-аутентификация»no retry
— (forbidden)403нет permission user-activity.stats.view«Недостаточно прав»no retry
VALIDATION_FAILED400PropertyObjectIds пустой / > 200 / содержит Guid.Empty«Некорректный список идентификаторов объектов недвижимости»no retry

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

Acceptance-критерии

  • Given список из N существующих и M неизвестных PropertyObjectId (после дедупликации N + M ≤ 200), When GET .../view-stats, Then 200, items — только N строк, requestedCount = N + M, foundCount = N.
  • Given неизвестный (ни разу не просмотренный) PropertyObjectId, When GET, Then его нет в items; потребитель трактует отсутствие как нулевую статистику (ViewsCount = 0, INV-LVS-001) — синтетическая нулевая строка не возвращается.
  • Given найденная строка, When GET, Then avgDuration = TotalDuration / ViewsCount0 при ViewsCount = 0), значение вычислено на чтении, не хранится (INV-LVS-005).
  • Given список пуст, содержит Guid.Empty или > 200 элементов, When GET, Then VALIDATION_FAILED (400).
  • Given вызывающий без permission user-activity.stats.view, When GET, Then 403; без service-токена — 401.
  • Given объект недвижимости с открытыми (Active, ещё не закрытыми) просмотрами, When GET, Then они ещё не учтены в счётчиках (freshness eventual — лаг консюмера property-object-view-ended + polling-цикл activity-closer); фактическая свежесть строки — в updatedAt.
  • Given все запрошенные PropertyObjectId неизвестны, When GET, Then 200, items = [], foundCount = 0 (не ошибка).

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

  • Новые optional-поля PropertyObjectViewStatsModel/результата — additive (non-breaking); удаление/переименование/ смена типа — breaking (migration + скоординированный rollout catalog). Лимит PropertyObjectIds (200) — часть контракта; изменение — с уведомлением потребителя.
  • Logs: property-object-view-stats-batch requested (requestedCount, foundCount, callerService; сами id — не пишутся); metrics user_activity_property_object_view_stats_batch_total{result}, histogram размера батча, latency p95 (< 1 c, internal-SLO README.md); traces — сквозной trace через traceparent; dashboard pin-user-activity; runbook — docs/06-services/user-activity/runbooks/ (будущий путь по конвенции).

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

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

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

  • Дом полей/инвариантов: property-object-view-stats.md (агрегат, консюмер-инкремент); источник инкрементов: property-object-view.md (событие property-object-view-ended).
  • Потребитель счётчиков: ../../../catalog/api/public/get-property-objects.md (сортировка «Популярные», бейджи); карта API: ../README.md.
  • Событие-источник: ../events/property-object-view-ended.md (будущий путь по конвенции); разграничение с dev-stats — ADR-0057; вне tenant — ADR-0052 п.3; simplicity-first — ADR-0056.