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) накопленную статистику просмотров по набору объектов недвижимости одним запросом: для
каждого известного PropertyObjectId — ViewsCount, TotalDuration, вычисляемая AvgDuration, LastViewedAt.
Owning-контекст — user-activity, модуль views, feature group views. Многомерная аналитика
застройщика (даты/зрители/иерархия, 3 года) сюда не входит — это dev-stats/ClickHouse (ADR-0057).
| Поле | Значение |
|---|
| Сервис/контекст | user-activity |
| API area | internal (m2m: catalog) |
| Feature group | views |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/internal/get-property-object-view-stats.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | System / ExternalSystem — сервис catalog (m2m, internal-gateway + service auth) |
| Auth policy / Permissions | m2m 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-интерсепторы не подключаются |
| Audit | none (чтение платформенных счётчиков; PII нет — только id и длительности) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/internal/user-activity/property-objects/view-stats |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
CallerService | service auth (m2m identity) | string | Да | сервис с permission user-activity.stats.view; иначе 403 | — |
PropertyObjectIds | query (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 | Описание |
|---|
items | IReadOnlyList<PropertyObjectViewStatsModel> | Да | найденные строки по уникальному списку PropertyObjectId | — | Найденные строки (только просмотренные хотя бы раз объекты недвижимости). |
requestedCount | int | Да | computed | — | Число уникальных запрошенных PropertyObjectId. |
foundCount | int | Да | items.Count | — | Сколько строк найдено (requested - found — объекты недвижимости без единого закрытого просмотра). |
asOf | DateTimeOffset | Да | server now() | — | Момент формирования ответа (маркер свежести, серверное время). |
PropertyObjectViewStatsModel (проекция агрегата — дом полей property-object-view-stats.md, здесь только дельта)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
propertyObjectId | Guid | Да | PropertyObjectViewStats.Id (= PropertyObjectId) | — | Объект недвижимости. |
viewsCount | long | Да | ViewsCount | — | Сколько раз объект недвижимости просматривался (закрытые просмотры). |
totalDuration | TimeSpan | Да | TotalDuration (PG interval) | — | Суммарная длительность (JSON — ISO-8601 duration). |
avgDuration | TimeSpan | Да | computed TotalDuration / ViewsCount (0 при ViewsCount = 0) — INV-LVS-005 | — | Средняя длительность (не хранится, считается на чтении). |
lastViewedAt | DateTimeOffset | Да | LastViewedAt | — | Момент окончания последнего просмотра. |
updatedAt | DateTimeOffset | Да | UpdatedAt | — | Обновление строки (лаг консюмера property-object-view-ended = updatedAt vs lastViewedAt). |
Отсутствующий в items PropertyObjectId потребитель трактует как нулевую статистику (ViewsCount = 0): строка
PropertyObjectViewStats создаётся первым событием property-object-view-ended (INV-LVS-001), никогда-не-просмотренный объект недвижимости
строки не имеет. Синтетические нулевые строки не возвращаются (в выдаче — только реально существующие строки).
Алгоритм
- Контекст и доступ. Вызывает сервис
catalog (m2m internal); требуется permission
user-activity.stats.view — иначе 403. Запрос вне tenant (платформенный агрегат, ADR-0052 п.3).
- Проверка входа.
PropertyObjectIds непустой, после дедупликации содержит 1..200 элементов,
каждый != Guid.Empty — иначе VALIDATION_FAILED (400). Существование объектов не проверяется:
неизвестные/битые PropertyObjectId (объект ни разу не просмотрен, строки PropertyObjectViewStats нет —
INV-LVS-001) молча отсутствуют в выдаче, это не ошибка. Состояния у агрегата нет (нет Status);
record-level по владельцу отсутствует (агрегат без зрителя) — единственный гейт доступа проверен в шаге 1.
- Что читаем. Накопленные счётчики
PropertyObjectViewStats по уникальному списку PropertyObjectId
(ключ — сам PropertyObjectId, INV-LVS-001). Возвращаются только существующие строки.
- Расчёт и маппинг. Маппинг поле-в-поле — таблица
PropertyObjectViewStatsModel;
avgDuration = ViewsCount > 0 ? TotalDuration / ViewsCount : 0 (INV-LVS-005 DerivedNotStored — не хранится,
чтобы не рассинхронизироваться с инкрементами). requestedCount = число уникальных запрошенных id,
foundCount = число найденных строк, asOf = серверное now().
- Результат.
200 с найденными строками. Консистентность eventual: счётчики отстают на лаг
консюмера property-object-view-ended, а незакрытые (Active) просмотры ещё не учтены (закрытие —
polling-worker activity-closer 10–15 мин, ADR-0056 п.3); свежесть строки — в updatedAt. Пустой
items (все PropertyObjectId неизвестны) — валидный ответ 200.
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none (safe read) |
| External calls | none — чтение накопленных счётчиков по списку PropertyObjectId |
| Cache / projections / search | чтение PK btree(id); собственного кэша нет (ADR-0056); бейджи популярности catalog кэширует на своей стороне |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | нет/невалиден m2m service-token | «Требуется service-аутентификация» | no retry |
| — (forbidden) | 403 | нет permission user-activity.stats.view | «Недостаточно прав» | no retry |
VALIDATION_FAILED | 400 | PropertyObjectIds пустой / > 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 / ViewsCount (и 0 при
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/ (будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы