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

GET /api/v1/dev-stats/views/trend — Динамика просмотров + сравнение периодов

Назначение

Временной ряд просмотров недвижимости застройщика с грануляцией day/week/month и сравнением с предыдущим периодом равной длины (delta + рост, %). Источник графика «динамика внимания» на дашборде застройщика (../../README.md, метрика «Динамика просмотров»). Ряд считается из дневного rollup-а property_object_view_daily (AggregatingMergeTree, *Merge-функции, без FINAL); фильтрация по иерархии и типу зрителя — префикс-скан по ключу rollup-а.

Metadata

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

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

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

HTTP-контракт

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

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

Общий вход — DevStatsFilterModel (../../domain/dev-stats-filter.md) + грануляция ряда.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DeveloperIdidentity claimsGuidДа!= Guid.Empty; = актор (иной id → Forbidden)Tenant isolation
DateFromquery dateFromDateOnlyДаISO yyyy-MM-dd; DateFrom <= DateTo; окно ≤ 366 дней (guard числа точек day)
DateToquery dateToDateOnlyДаISO; ≤ сегодня (UTC)
Granularityquery granularityViewsGranularity (enum)Нетwire day/week/month; default day; неизвестное → ValidationError (молчаливый дефолт запрещён)
Comparequery compareboolНетdefault true; false — без предыдущего периода
ProjectIdquery projectIdGuid?Нетreference dim_project (существование не проверяется — пустой ряд)
BuildingIdquery buildingIdGuid?Неттребует ProjectId
LayoutGroupIdquery layoutGroupIdGuid?Нет
LayoutIdquery layoutIdGuid?Нет
PropertyObjectIdquery propertyObjectIdGuid?Нет
ViewerKindquery viewerKindViewerKind? (enum)НетGuest/Agent/Investor (dimension rollup-а)
ViewSourcequery viewSourceViewSource? (enum)Нетесли задан — чтение уходит на сырые property_object_view_facts (rollup не несёт ViewSource), p95 ≤ 600 мс

Enum ViewsGranularity: Day/Week/Month (wire — нижний регистр; хранение — .HasConversion<string>()).

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

ПолеТипОбязательностьИсточникMaskingОписание
granularitystring (enum wire)Даecho (после дефолта)Применённая грануляция.
dateFromstring (ISO date)ДаechoНачало текущего периода.
dateTostring (ISO date)ДаechoКонец текущего периода.
pointsIReadOnlyList<ViewsTrendPointModel>Да (может быть пуст)агрегация property_object_view_dailyТочки текущего ряда по возрастанию date.
comparisonViewsTrendComparisonModel?Нетшаг 4Итоги/дельты против предыдущего периода; null при compare=false.

ViewsTrendPointModel

ПолеТипОбязательностьИсточникОписание
datestring (ISO date)Даначало бакета (EventDate/toMonday/toStartOfMonth)Дата точки (начало периода бакета).
viewslongДаuniqMerge(Views)Число просмотров (уник. по PropertyObjectViewId).
uniqViewerslongДаuniqMerge(UniqViewers)Уникальные зрители.
durationSumlongДаsumMerge(DurationSum)Суммарная длительность, сек.
avgDurationintДаsumMerge(DurationSum) / uniqMerge(Views) (0 при views=0)Средняя длительность на просмотр, сек.

ViewsTrendComparisonModel

ПолеТипОбязательностьИсточникОписание
previousFromstring (ISO date)ДаDateFrom − (DateTo − DateFrom + 1)Начало предыдущего периода равной длины.
previousTostring (ISO date)ДаDateFrom − 1 деньКонец предыдущего периода.
viewsCurrentlongДасумма views текущегоВсего просмотров за текущий период.
viewsPreviouslongДасумма views предыдущегоВсего просмотров за предыдущий период.
viewsDeltalongДаviewsCurrent − viewsPreviousАбсолютная дельта.
viewsGrowthPercentdecimal?Нетround1((current/previous − 1) × 100); null при previous=0Рост, %, 1 знак.
uniqViewersGrowthPercentdecimal?Нетаналогично по уникальным зрителямРост уникальных зрителей, %.

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

  • Пользователь застройщика авторизован; есть 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): правый край ряда (сегодняшний бакет) в окне недооценивает просмотры и монотонно догоняет — деградация, не ошибка. Сравнение с предыдущим периодом на это устойчиво (сдвинутое окно уже сведено).
  • При compare=true предыдущий период равной длины [previousFrom, previousTo] захватывается той же выборкой (один проход на оба периода), поэтому итоги согласованы между собой.
  • Операция read-only: состояние не меняется, события не публикуются.

Алгоритм

  1. Контекст и доступ. Операцию выполняет пользователь застройщика (роль developer) с правом dev-stats.view. DeveloperId берётся из identity-claims актора; если клиент прислал иной id → отказ Forbidden (403).
  2. Проверка входа. Granularity ∈ {day,week,month} (неизвестное → ValidationError, молчаливый дефолт запрещён; default day); DateFrom <= DateTo, DateTo ≤ сегодня (UTC), окно ≤ 366 дней (guard числа точек). Существование иерархических id не проверяется: неизвестный id даёт пустой ряд.
  3. Что читаем. Дневной rollup property_object_view_daily за период по заданному уровню иерархии (при заданном ViewSource — сырьё property_object_view_facts, p95 ≤ 600 мс). Заданные уровни иерархии — префикс-скан по ключу rollup-а (DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId, PropertyObjectId, EventDate, ViewerKind); диапазон дат режет партиции toYYYYMM(EventDate). При compare=true тем же запросом захватывается расширенный диапазон [previousFrom, DateTo] (одна выборка на оба периода).
  4. Агрегация и маппинг. Группировка по бакету: bucket = EventDate (day) / toMonday(EventDate) (week) / toStartOfMonth(EventDate) (month), ORDER BY bucket ASC; меры dedup-tolerant — uniqMerge(Views), uniqMerge(UniqViewers), sumMerge(DurationSum)без FINAL. Точки bucket >= DateFrompoints; точки предыдущего окна суммируются в comparison. avgDuration = durationSum / views (guard views>0); viewsGrowthPercent/uniqViewersGrowthPercent — guard previous>0 (иначе null).
  5. Результат. Возвращается ViewsTrendResult (200): ряд points и блок comparison. Freshness ленты ≤ 5 мин (SLA консюмера); недоступность ClickHouse → DEV_STATS_UNAVAILABLE (503).

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Невалидные granularity/диапазон/окно > 366 днейОшибка валидации запросаno retry
UNAUTHORIZED401Нет JWTТребуется авторизацияno retry
Forbidden403DeveloperId != актор или нет dev-stats.viewДоступ запрещёнno retry
DEV_STATS_UNAVAILABLE503ClickHouse недоступен / timeoutАналитика временно недоступнаretry с backoff

Пустой ряд (нет просмотров) — не ошибка: 200 с points = [], comparison с нулевыми итогами.

Acceptance Criteria

  • Ряд со сравнением. Дано корректный период и compare=true. Когда GET /views/trend?granularity=day. Тогда 200, points по возрастанию date, comparison с previousFrom/previousTo, viewsDelta и viewsGrowthPercent.
  • Грануляция месяца. Дано granularity=month. Когда запрос. Тогда точки выровнены на начало месяца (toStartOfMonth), а granularity в ответе — month.
  • Без сравнения. Дано compare=false. Когда запрос. Тогда comparison = null, points присутствуют.
  • Guard роста. Дано viewsPrevious = 0. Когда считается рост. Тогда viewsGrowthPercent = null (не деление на ноль), viewsDelta = viewsCurrent.
  • Слишком длинное дневное окно. Дано окно > 366 дней. Когда запрос. Тогда 400 ValidationError (guard числа точек).
  • Будущая граница. Дано dateTo > сегодня (UTC). Когда запрос. Тогда 400 ValidationError.
  • Неизвестная грануляция. Дано granularity=hour. Когда запрос. Тогда 400 ValidationError (молчаливый дефолт запрещён).
  • Чужой scope. Дано клиент подставил чужой DeveloperId. Когда запрос. Тогда 403 Forbidden (INV-STA-1).
  • Пустой ряд. Дано период без просмотров. Когда запрос. Тогда 200, points = [], comparison с нулевыми итогами (не ошибка).
  • Срез по источнику. Дано задан viewSource. Когда запрос. Тогда чтение идёт по сырью, контракт тот же, SLO p95 ≤ 600 мс.
  • Окно свежести. Дано просмотр минуту назад в сегодняшнем бакете. Когда запрос. Тогда правый край ряда недооценивает его; после ingestion (≤ 5 мин) точка монотонно растёт (INV-STA-5).
  • Недоступность витрины. Дано ClickHouse недоступен. Когда запрос. Тогда 503 DEV_STATS_UNAVAILABLE (retry с backoff).

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

  • Новые optional поля ответа — additive; удаление/переименование/смена wire-значений granularity — breaking (версия метода). Unknown enum → ValidationError (не дефолт).
  • Logs: views-trend-read (developerId, granularity, pointsCount, compare, elapsed; PII нет). Metrics: dev_stats_views_trend_requests_total{granularity}, latency histogram (SLO p95 ≤ 300 мс rollup, ≤ 600 мс при ViewSource../../README.md). Traces: span приёма → span обработки → span чтения ClickHouse; correlation — сквозной trace traceparent.

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

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

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