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

GET /api/v1/dev-stats/views/summary — Сводка просмотров (кто/сколько/как долго/как часто)

Назначение

Одна агрегированная сводка внимания к недвижимости застройщика за период по выбранному уровню иерархии (проект / здание / группа планировок / планировка / объект недвижимости — задаётся набором фильтров DevStatsFilterModel): сколько просмотров, кто смотрел (уникальные зрители и их типы), как долго (средняя длительность) и как часто (повторные просмотры). Отдаёт headline-числа totalViews / uniqueViewers / avgDurationSeconds / repeatRate плюс разбивку по ViewerKind (Guest / Agent / Investor). Источник карточек-KPI дашборда ЛК застройщика (панель dev-stats-overview собирает несколько таких метрик). Читает дневной rollup property_object_view_daily без сканирования сырых фактов.

Metadata

ПолеЗначение
Сервис/контекстdev-stats
API areadevelopers (read-only ЛК-аналитика)
Feature groupviews
Статусdraft
Документdocs/06-services/dev-stats/api/developers/get-views-summary.md

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

ПроверкаЗначение
Actor typeUser (Bearer JWT: пользователь застройщика — менеджер developer-компании)
Auth policy / Permissionspermission dev-stats.view (каталог — identity/domain/permission.md)
Scope (RBAC)организация застройщика: scope строго = свой DeveloperId
Record-levelне применяется (агрегаты по объектам компании, не по записям пользователя)
Tenant isolationTenantId = DeveloperId; фильтр DeveloperId обязателен и = актор (fail-closed, injection из identity, не из клиента)
Auditnone (read-агрегат без PII; выгрузка — отдельный dev-stats.export)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/dev-stats/views/summary
Success status200
Idempotency headerне применяется (safe GET)
Correlationсквозной trace через traceparent

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

Общий вход — DevStatsFilterModel. DeveloperId берётся из identity-claims актора (scope), не принимается от клиента.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DeveloperIdidentity claimsGuidДа!= Guid.Empty; = актор (клиентское значение отвергается → 403)Tenant isolation, fail-closed
DateFromquery dateFromDateOnlyДаISO yyyy-MM-dd
DateToquery dateToDateOnlyДаISO; DateFrom <= DateTo; окно ≤ 3 года (= TTL фактов)
ProjectIdquery projectIdGuid?Нетуровень иерархии (сужает scope)
BuildingIdquery buildingIdGuid?Неттребует заданного ProjectId (иерархия сверху вниз)
LayoutGroupIdquery layoutGroupIdGuid?Нет
LayoutIdquery layoutIdGuid?Нет
PropertyObjectIdquery propertyObjectIdGuid?Нетсамый глубокий уровень
ViewerKindquery viewerKindViewerKind? (enum)Нетwire guest/agent/investor; неизвестное → ValidationError (молчаливый дефолт запрещён)
ViewSourcequery viewSourceViewSource? (enum)Нетwire catalog/search/selection/favorites/news/direct-link/unknown

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

ПолеТипОбязательностьИсточникMaskingОписание
developerIdGuidДаecho актораЗастройщик (= scope).
dateFrom / dateTostring (ISO date)ДаechoПрименённый период.
levelstring (enum wire)ДавычисленоСамый глубокий заданный уровень иерархии: developer/project/building/layout-group/layout/property-object.
totalViewslongДаuniqMerge(Views)Всего просмотров (dedup по PropertyObjectViewId).
uniqueViewerslongДаuniqMerge(UniqViewers)агрегатУникальные зрители (coalesce(UserId, AnonymousId)), дедуп за весь период.
totalDurationSecondslongДаsumMerge(DurationSum)Суммарная длительность просмотров, сек.
avgDurationSecondsintДаsumMerge(DurationSum) / uniqMerge(Views) (guard 0)Средняя длительность просмотра, сек.
repeatViewslongДаuniqMerge(Views) − uniqMerge(UniqViewers)Просмотры сверх первого на каждого уникального зрителя.
repeatRatedecimalДаrepeatViews / totalViews (guard 0)Доля повторных просмотров, 0..1 (2 знака).
byViewerKindIReadOnlyList<ViewerKindSummaryModel>ДаGROUP BY ViewerKindагрегатРазбивка по типу зрителя.

ViewerKindSummaryModel

ПолеТипИсточникОписание
viewerKindstring (enum wire)ViewerKindguest / agent / investor.
viewslonguniqMerge(Views)Просмотры этого типа зрителя.
uniqueViewerslonguniqMerge(UniqViewers)Уникальные зрители типа.
avgDurationSecondsintsumMerge/uniqMergeСредняя длительность, сек.
sharedecimalviews / totalViews (guard 0)Доля в общем числе просмотров, 0..1.

Предусловия и согласованность данных

  • Пользователь застройщика авторизован; есть permission dev-stats.view; scope DeveloperId разрешён и равен актору (INV-STA-1, fail-closed — ../../README.md).
  • Сводка наполняется batch-консюмером из pin.user-activity.property-object-view-ended.v1; окно свежести событие→факт ≤ 5 мин (INV-STA-5). В окне headline-числа недооценивают самые свежие просмотры — деградация, не ошибка; после подтверждённой вставки счётчики монотонно догоняют.
  • uniqueViewers дедуплен за весь период кросс-день (INV-STA-4): зритель, заходивший в разные дни, учтён один раз, поэтому repeatViews = totalViews − uniqueViewers ≥ 0.
  • Операция read-only: состояние сервиса не меняется, события не публикуются.

Алгоритм

  1. Контекст и доступ. Операцию выполняет пользователь застройщика (роль developer) с правом dev-stats.view. DeveloperId берётся из identity-claims актора (scope) и не принимается от клиента; если клиент прислал DeveloperId ≠ актор → отказ DEV_STATS_FORBIDDEN (403, fail-closed).
  2. Проверка входа. DateFrom/DateTo заданы и DateFrom <= DateTo, окно ≤ 3 года (= TTL фактов); enum ViewerKind/ViewSource — из допустимого множества (неизвестное значение → ValidationError, молчаливый дефолт запрещён); иерархия задаётся «сверху вниз» (нельзя BuildingId без ProjectId). Существование иерархии и статусы не проверяются: неизвестный id даёт пустую сводку, а не ошибку.
  3. Что читаем. Дневной rollup property_object_view_daily за период по заданному уровню иерархии. Один проход с GROUP BY ViewerKind WITH ROLLUP даёт и разбивку по типу зрителя, и общий итог. Меры считаются dedup-tolerant склейкой состояний — uniqMerge(Views), uniqMerge(UniqViewers), sumMerge(DurationSum)без FINAL. Диапазон EventDate режет партиции по месяцам (toYYYYMM); заданные уровни иерархии образуют префикс-скан по ключу rollup-а (DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId, PropertyObjectId, EventDate, ViewerKind). Если задан ViewSource (в rollup не хранится), чтение переключается на сырьё property_object_view_facts (медленнее, SLO p95 ≤ 600 мс).
  4. Расчёты и маппинг. avgDurationSeconds = totalDurationSeconds / totalViews (guard 0); repeatViews = totalViews − uniqueViewers; repeatRate = repeatViews / totalViews (guard 0, 2 знака); share каждого ViewerKind = его views / totalViews; level = самый глубокий заданный уровень иерархии. Пустой результат (нет фактов) → нули и пустой byViewerKind (не ошибка).
  5. Результат. Возвращается ViewsSummaryResult (200): headline-числа и разбивка byViewerKind. Недоступность ClickHouse → DEV_STATS_UNAVAILABLE (503).

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Нет dateFrom/dateTo, DateFrom > DateTo, окно > 3 лет, неизвестный enum, BuildingId без ProjectIdОшибка валидации запросаno retry
UNAUTHORIZED401Нет JWTТребуется авторизацияno retry
DEV_STATS_FORBIDDEN403Нет dev-stats.view или DeveloperId ≠ акторДоступ к статистике застройщика запрещёнno retry
DEV_STATS_UNAVAILABLE503ClickHouse недоступен / timeoutСтатистика временно недоступнаretry с backoff

Пустой период (нет фактов) — не ошибка: 200 с нулями и пустым byViewerKind. Фильтр ViewSource переводит запрос на срез по property_object_view_facts (в rollup ViewSource не хранится) — SLO p95 ≤ 600 мс вместо ≤ 300 мс.

Acceptance Criteria

  • Сводка по уровню. Дано авторизованный застройщик, корректный период и ProjectId своего проекта. Когда GET /views/summary. Тогда 200 с totalViews/uniqueViewers/avgDurationSeconds/repeatRate, разбивкой byViewerKind (сумма share ≈ 1) и level = project.
  • Пустой период. Дано период без фактов. Когда запрос. Тогда 200, все headline-числа = 0, byViewerKind = [] (не ошибка).
  • Кросс-день дедуп. Дано один зритель заходил в 3 разных дня периода. Когда считается uniqueViewers. Тогда он учтён как 1 (INV-STA-4), а repeatViews = totalViews − uniqueViewers.
  • Guard деления на ноль. Дано totalViews = 0. Когда считаются производные. Тогда avgDurationSeconds = repeatRate = share = 0 без ошибки.
  • Чужой scope. Дано клиент подставил DeveloperId другого застройщика. Когда запрос. Тогда 403 DEV_STATS_FORBIDDEN; чужие агрегаты не возвращаются (INV-STA-1, fail-closed).
  • Неизвестный enum. Дано viewerKind=vip. Когда запрос. Тогда 400 ValidationError (молчаливый дефолт запрещён).
  • Иерархия «снизу вверх». Дано BuildingId без ProjectId. Когда запрос. Тогда 400 ValidationError.
  • Окно шире TTL. Дано dateTo − dateFrom > 3 года. Когда запрос. Тогда 400 ValidationError (INV-STA-8).
  • Срез по источнику. Дано задан viewSource=search. Когда запрос. Тогда 200 с тем же контрактом, но чтение идёт по сырью (property_object_view_facts), SLO p95 ≤ 600 мс вместо ≤ 300 мс.
  • Окно свежести. Дано просмотр случился минуту назад и ещё не сведён в факт. Когда запрос. Тогда он не учтён; после ingestion (≤ 5 мин) totalViews монотонно возрастает (INV-STA-5), запрос не падает.
  • Недоступность витрины. Дано ClickHouse недоступен/timeout. Когда запрос. Тогда 503 DEV_STATS_UNAVAILABLE с подсказкой retry.

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

  • Новые optional поля ответа — additive; удаление/переименование/смена wire-enum ViewerKind/ViewSource — breaking (версия метода). Unknown enum на входе → ValidationError (не дефолт).
  • Logs: dev-stats-views-summary-read (developerId, level, dateFrom/dateTo, totalViews, elapsed; PII нет). Metrics: dev_stats_summary_requests_total{level}, latency histogram (SLO p95 ≤ 300 мс). Traces: span приёма запроса → span обработки → span чтения ClickHouse; correlation — сквозной trace traceparent. Dashboard/alerts — борд ingest (лаг влияет на свежесть, ../../README.md).

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

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

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