GET /api/v1/dev-stats/viewers/unique — Уникальные зрители (гость/агент/инвестор)
Назначение
Сколько уникальных зрителей смотрели объекты застройщика за период — общим числом, в разрезе типа
зрителя (Guest / Agent / Investor) и в динамике (ряд по дням/неделям/месяцам). Ключевая ценность —
корректный дедуп зрителя за весь период: уникальность считается uniqMerge-склейкой HLL-состояний
uniqState(coalesce(UserId, AnonymousId)) из rollup property_object_view_daily,
поэтому один зритель, заходивший в разные дни, в итоговом числе учтён один раз (простое суммирование
дневных счётчиков этого не даёт). Источник блока «аудитория» дашборда ЛК застройщика.
| Поле | Значение |
|---|
| Сервис/контекст | dev-stats |
| API area | developers |
| Feature group | viewers |
| Статус | draft |
| Документ | docs/06-services/dev-stats/api/developers/get-unique-viewers.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Bearer JWT: пользователь застройщика) |
| Auth policy / Permissions | permission dev-stats.view (identity/domain/permission.md) |
| Scope (RBAC) | организация застройщика: scope строго = свой DeveloperId |
| Record-level | не применяется |
| Tenant isolation | TenantId = DeveloperId; фильтр DeveloperId обязателен и = актор (fail-closed, из identity) |
| Audit | none (агрегат без PII — только счётчики и типы) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/dev-stats/viewers/unique |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Общий вход — DevStatsFilterModel + грануляция ряда. DeveloperId — из
identity-claims актора.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
DeveloperId | identity claims | Guid | Да | != Guid.Empty; = актор (иначе 403) | Tenant isolation, fail-closed |
DateFrom | query dateFrom | DateOnly | Да | ISO yyyy-MM-dd | — |
DateTo | query dateTo | DateOnly | Да | ISO; DateFrom <= DateTo; окно ≤ 3 года | — |
ProjectId…PropertyObjectId | query | Guid? | Нет | уровни иерархии (сверху вниз) | — |
ViewerKind | query viewerKind | ViewerKind? | Нет | wire guest/agent/investor; unknown → ValidationError | — |
ViewSource | query viewSource | ViewSource? | Нет | wire значения ViewSource (переводит на срез фактов) | — |
Granularity | query granularity | SeriesGranularity (enum) | Нет | wire day/week/month; default day; unknown → ValidationError | — |
Модель ответа UniqueViewersResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
developerId | Guid | Да | echo актора | — | Застройщик (= scope). |
dateFrom / dateTo | string (ISO date) | Да | echo | — | Применённый период. |
granularity | string (enum wire) | Да | echo | — | Грануляция ряда day/week/month. |
totalUniqueViewers | long | Да | uniqMerge(UniqViewers) (без groupBy) | агрегат | Уникальные зрители за весь период (дедуп кросс-день). |
byViewerKind | IReadOnlyList<ViewerKindCountModel> | Да | GROUP BY ViewerKind | агрегат | Разрез гость/агент/инвестор. |
series | IReadOnlyList<UniqueViewersPointModel> | Да (может быть пуст) | GROUP BY bucket | агрегат | Динамика уникальных зрителей по бакетам. |
ViewerKindCountModel
| Поле | Тип | Источник | Описание |
|---|
viewerKind | string (enum wire) | ViewerKind | guest/agent/investor. |
uniqueViewers | long | uniqMerge(UniqViewers) | Уникальные зрители типа за период. |
share | decimal | uniqueViewers / totalUniqueViewers (guard 0) | Доля типа, 0..1 (2 знака). |
UniqueViewersPointModel
| Поле | Тип | Источник | Описание |
|---|
date | string (ISO date) | начало бакета (EventDate / toStartOfWeek / toStartOfMonth) | Точка ряда. |
uniqueViewers | long | uniqMerge(UniqViewers) внутри бакета | Уникальные зрители бакета. |
Предусловия и согласованность данных
- Пользователь застройщика авторизован; есть permission
dev-stats.view; scope DeveloperId = актор
(INV-STA-1, fail-closed — ../../README.md).
- Уникальность считается кросс-день (INV-STA-4):
uniqMerge сливает HLL-состояния всех дней периода, зритель
из разных дней учтён один раз. Ожидаемое свойство: Σ series[*].uniqueViewers ≥ totalUniqueViewers
(зритель в двух бакетах учтён в каждом, а в total — один раз) — это свойство дедупа, а не расхождение.
- Окно свежести событие→факт ≤ 5 мин (INV-STA-5): свежайшие зрители в окне недосчитаны, число монотонно
догоняет.
- Операция read-only: состояние не меняется, события не публикуются.
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь застройщика (роль developer) с правом
dev-stats.view. DeveloperId берётся из identity-claims актора (scope); если клиент прислал
DeveloperId ≠ актор → отказ DEV_STATS_FORBIDDEN (403, fail-closed).
- Проверка входа. Период задан и корректен (
DateFrom <= DateTo, окно ≤ 3 года); enum
ViewerKind/ViewSource/Granularity — из допустимого множества (неизвестное → ValidationError,
молчаливый дефолт запрещён); иерархия «сверху вниз». Существование иерархии не проверяется: неизвестный
id даёт пустой результат.
- Что читаем. Дневной rollup
property_object_view_daily за период по scope. За один префикс-скан по
ключу (DeveloperId, ProjectId, …, PropertyObjectId, EventDate, ViewerKind) считаем:
- итог и разрез —
uniqMerge(UniqViewers) с GROUP BY ViewerKind WITH ROLLUP: grand-total
(totalUniqueViewers) и byViewerKind. Дедуп зрителя за период: uniqMerge сливает HLL-состояния
всех дней, зритель из разных дней учтён один раз;
- ряд —
uniqMerge(UniqViewers) с GROUP BY bucket (EventDate / toStartOfWeek / toStartOfMonth),
ORDER BY bucket.
Меры считаются без FINAL; диапазон дат режет партиции toYYYYMM(EventDate). Если задан
ViewSource (в rollup не хранится), чтение уходит на сырьё
property_object_view_facts с uniq(coalesce(UserId, AnonymousId)).
- Расчёты и маппинг.
share каждого ViewerKind = uniqueViewers / totalUniqueViewers (guard 0);
series[k].date ← начало бакета. Ожидаемое свойство: Σ series[*].uniqueViewers ≥ totalUniqueViewers
(зритель в двух бакетах считается в каждом, а в total — один раз) — документируется, не «чинится».
Пустой период → нули, пустые byViewerKind/series.
- Результат. Возвращается
UniqueViewersResult (200). Недоступность ClickHouse →
DEV_STATS_UNAVAILABLE (503).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Нет/некорректный период, окно > 3 лет, неизвестный enum/granularity, иерархия «снизу вверх» | Ошибка валидации запроса | no retry |
UNAUTHORIZED | 401 | Нет JWT | Требуется авторизация | no retry |
DEV_STATS_FORBIDDEN | 403 | Нет dev-stats.view или DeveloperId ≠ актор | Доступ к статистике застройщика запрещён | no retry |
DEV_STATS_UNAVAILABLE | 503 | ClickHouse недоступен / timeout | Статистика временно недоступна | retry с backoff |
Пустой период — не ошибка: 200 с нулями и пустыми списками.
Acceptance Criteria
- Итог, разрез и ряд. Дано авторизованный застройщик и корректный период. Когда
GET /viewers/unique?granularity=day. Тогда 200 с totalUniqueViewers, byViewerKind (сумма
share ≈ 1) и series по возрастанию date.
- Кросс-день дедуп. Дано один зритель заходил в понедельник и вторник. Когда считается
totalUniqueViewers. Тогда он учтён как 1 (INV-STA-4), при этом series за оба дня даёт по 1, то есть
Σ series ≥ total.
- Пустой период. Дано период без просмотров. Когда запрос. Тогда
200, нули и пустые
byViewerKind/series (не ошибка).
- Guard доли. Дано
totalUniqueViewers = 0. Когда считается share. Тогда share = 0 без ошибки.
- Чужой scope. Дано клиент подставил чужой
DeveloperId. Когда запрос. Тогда 403 DEV_STATS_FORBIDDEN
(INV-STA-1).
- Неизвестный enum/грануляция. Дано
viewerKind=vip или granularity=hour. Когда запрос. Тогда
400 ValidationError (молчаливый дефолт запрещён).
- Иерархия «снизу вверх» / окно > 3 лет. Дано
BuildingId без ProjectId либо окно шире TTL. Когда
запрос. Тогда 400 ValidationError (INV-STA-8).
- Срез по источнику. Дано задан
viewSource. Когда запрос. Тогда уникальность считается по сырью
(uniq(coalesce(UserId, AnonymousId))), контракт тот же, SLO p95 ≤ 600 мс.
- Окно свежести. Дано новый зритель минуту назад. Когда запрос. Тогда он ещё не учтён; после ingestion
(≤ 5 мин)
totalUniqueViewers монотонно растёт (INV-STA-5).
- Недоступность витрины. Дано ClickHouse недоступен. Когда запрос. Тогда
503 DEV_STATS_UNAVAILABLE
(retry с backoff).
Совместимость и наблюдаемость
- Optional-поля ответа — additive; смена wire-enum
ViewerKind/Granularity — breaking. Unknown enum → 400.
- Logs:
dev-stats-unique-viewers-read (developerId, granularity, totalUniqueViewers, elapsed; PII нет).
Metrics: dev_stats_unique_viewers_requests_total{granularity}, latency histogram (SLO p95 ≤ 300 мс).
Traces: span приёма → span обработки → span чтения ClickHouse; correlation — сквозной trace traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы