GET /api/v1/dev-stats/views/trend — Динамика просмотров + сравнение периодов
Назначение
Временной ряд просмотров недвижимости застройщика с грануляцией day/week/month и сравнением с
предыдущим периодом равной длины (delta + рост, %). Источник графика «динамика внимания» на дашборде
застройщика (../../README.md, метрика «Динамика просмотров»). Ряд считается из дневного
rollup-а property_object_view_daily (AggregatingMergeTree, *Merge-функции,
без FINAL); фильтрация по иерархии и типу зрителя — префикс-скан по ключу rollup-а.
| Поле | Значение |
|---|
| Сервис/контекст | dev-stats |
| API area | developers |
| Feature group | views-trend |
| Статус | draft |
| Документ | docs/06-services/dev-stats/api/developers/get-views-trend.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Bearer JWT; роль застройщика — developer) |
| Auth policy / Permissions | permission dev-stats.view — каталог identity/domain/permission.md |
| Scope (RBAC) | организация застройщика; DeveloperId строго = актор (fail-closed при чужом id) |
| Record-level | не применяется (агрегаты, не записи) |
| Tenant isolation | TenantId = DeveloperId обязателен в каждом запросе и равен актору |
| Audit | none (read-агрегаты; персональные данные зрителей не выдаются — только счётчики/типы) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/dev-stats/views/trend |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Общий вход — DevStatsFilterModel (../../domain/dev-stats-filter.md) +
грануляция ряда.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
DeveloperId | identity claims | Guid | Да | != Guid.Empty; = актор (иной id → Forbidden) | Tenant isolation |
DateFrom | query dateFrom | DateOnly | Да | ISO yyyy-MM-dd; DateFrom <= DateTo; окно ≤ 366 дней (guard числа точек day) | — |
DateTo | query dateTo | DateOnly | Да | ISO; ≤ сегодня (UTC) | — |
Granularity | query granularity | ViewsGranularity (enum) | Нет | wire day/week/month; default day; неизвестное → ValidationError (молчаливый дефолт запрещён) | — |
Compare | query compare | bool | Нет | default true; false — без предыдущего периода | — |
ProjectId | query projectId | Guid? | Нет | reference dim_project (существование не проверяется — пустой ряд) | — |
BuildingId | query buildingId | Guid? | Нет | требует ProjectId | — |
LayoutGroupId | query layoutGroupId | Guid? | Нет | — | — |
LayoutId | query layoutId | Guid? | Нет | — | — |
PropertyObjectId | query propertyObjectId | Guid? | Нет | — | — |
ViewerKind | query viewerKind | ViewerKind? (enum) | Нет | Guest/Agent/Investor (dimension rollup-а) | — |
ViewSource | query viewSource | ViewSource? (enum) | Нет | если задан — чтение уходит на сырые property_object_view_facts (rollup не несёт ViewSource), p95 ≤ 600 мс | — |
Enum ViewsGranularity: Day/Week/Month (wire — нижний регистр; хранение — .HasConversion<string>()).
Модель ответа ViewsTrendResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
granularity | string (enum wire) | Да | echo (после дефолта) | — | Применённая грануляция. |
dateFrom | string (ISO date) | Да | echo | — | Начало текущего периода. |
dateTo | string (ISO date) | Да | echo | — | Конец текущего периода. |
points | IReadOnlyList<ViewsTrendPointModel> | Да (может быть пуст) | агрегация property_object_view_daily | — | Точки текущего ряда по возрастанию date. |
comparison | ViewsTrendComparisonModel? | Нет | шаг 4 | — | Итоги/дельты против предыдущего периода; null при compare=false. |
ViewsTrendPointModel
| Поле | Тип | Обязательность | Источник | Описание |
|---|
date | string (ISO date) | Да | начало бакета (EventDate/toMonday/toStartOfMonth) | Дата точки (начало периода бакета). |
views | long | Да | uniqMerge(Views) | Число просмотров (уник. по PropertyObjectViewId). |
uniqViewers | long | Да | uniqMerge(UniqViewers) | Уникальные зрители. |
durationSum | long | Да | sumMerge(DurationSum) | Суммарная длительность, сек. |
avgDuration | int | Да | sumMerge(DurationSum) / uniqMerge(Views) (0 при views=0) | Средняя длительность на просмотр, сек. |
ViewsTrendComparisonModel
| Поле | Тип | Обязательность | Источник | Описание |
|---|
previousFrom | string (ISO date) | Да | DateFrom − (DateTo − DateFrom + 1) | Начало предыдущего периода равной длины. |
previousTo | string (ISO date) | Да | DateFrom − 1 день | Конец предыдущего периода. |
viewsCurrent | long | Да | сумма views текущего | Всего просмотров за текущий период. |
viewsPrevious | long | Да | сумма views предыдущего | Всего просмотров за предыдущий период. |
viewsDelta | long | Да | viewsCurrent − viewsPrevious | Абсолютная дельта. |
viewsGrowthPercent | decimal? | Нет | round1((current/previous − 1) × 100); null при previous=0 | Рост, %, 1 знак. |
uniqViewersGrowthPercent | decimal? | Нет | аналогично по уникальным зрителям | Рост уникальных зрителей, %. |
Предусловия и согласованность данных
- Пользователь застройщика авторизован; есть 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: состояние не меняется, события не публикуются.
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь застройщика (роль developer) с правом
dev-stats.view. DeveloperId берётся из identity-claims актора; если клиент прислал иной id → отказ
Forbidden (403).
- Проверка входа.
Granularity ∈ {day,week,month} (неизвестное → ValidationError, молчаливый
дефолт запрещён; default day); DateFrom <= DateTo, DateTo ≤ сегодня (UTC), окно ≤ 366 дней (guard
числа точек). Существование иерархических id не проверяется: неизвестный id даёт пустой ряд.
- Что читаем. Дневной 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] (одна выборка
на оба периода).
- Агрегация и маппинг. Группировка по бакету:
bucket = EventDate (day) / toMonday(EventDate)
(week) / toStartOfMonth(EventDate) (month), ORDER BY bucket ASC; меры dedup-tolerant —
uniqMerge(Views), uniqMerge(UniqViewers), sumMerge(DurationSum) — без FINAL. Точки
bucket >= DateFrom → points; точки предыдущего окна суммируются в comparison. avgDuration = durationSum / views (guard views>0); viewsGrowthPercent/uniqViewersGrowthPercent — guard
previous>0 (иначе null).
- Результат. Возвращается
ViewsTrendResult (200): ряд points и блок comparison. Freshness ленты
≤ 5 мин (SLA консюмера); недоступность ClickHouse → DEV_STATS_UNAVAILABLE (503).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Невалидные granularity/диапазон/окно > 366 дней | Ошибка валидации запроса | no retry |
UNAUTHORIZED | 401 | Нет JWT | Требуется авторизация | no retry |
Forbidden | 403 | DeveloperId != актор или нет dev-stats.view | Доступ запрещён | no retry |
DEV_STATS_UNAVAILABLE | 503 | ClickHouse недоступен / 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.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы