GET /api/v1/dev-stats/repeat-frequency — Повторные просмотры и частота возврата на объект
Назначение
Насколько часто зрители возвращаются к объектам застройщика — глубина повторного интереса. Считает по
парам «зритель × объект» (coalesce(UserId, AnonymousId) × PropertyObjectId) число заходов (uniq(PropertyObjectViewId)) и
строит распределение по бакетам частоты (1 / 2 / 3 / 4+ визита), плюс headline: доля возвращавшихся
пар (repeatRate) и среднее число визитов на пару. Дополняет сводный repeatViews из
get-views-summary детальным распределением, которого агрегатные меры rollup не
дают, поэтому читает сырьё property_object_view_facts (дедуп заходов по PropertyObjectViewId).
| Поле | Значение |
|---|
| Сервис/контекст | dev-stats |
| API area | developers |
| Feature group | repeat |
| Статус | draft |
| Документ | docs/06-services/dev-stats/api/developers/get-repeat-frequency.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 (агрегат-распределение; UserId/AnonymousId — только внутренний ключ группировки, наружу не выдаётся) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/dev-stats/repeat-frequency |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Общий вход — DevStatsFilterModel. DeveloperId — из identity-claims актора.
Границы бакетов частоты каноничны (1,2,3,4+), опционально переопределяются возрастающим массивом.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
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; окно ≤ 366 дней (сырьё — cost-guard) | — |
ProjectId…PropertyObjectId | query | Guid? | Нет | уровни иерархии (сверху вниз) | — |
ViewerKind | query viewerKind | ViewerKind? | Нет | wire guest/agent/investor; unknown → ValidationError | — |
ViewSource | query viewSource | ViewSource? | Нет | wire значения ViewSource | — |
VisitBucketEdges | query visitBuckets | IReadOnlyList<int>? | Нет | строго возрастающие, ≥ 1, ≤ 8 границ; default [1,2,3,4] (4 → бакет 4+) | — |
Модель ответа RepeatFrequencyResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
developerId | Guid | Да | echo актора | — | Застройщик (= scope). |
dateFrom / dateTo | string (ISO date) | Да | echo | — | Применённый период. |
viewerObjectPairs | long | Да | count() пар (зритель × объект) | агрегат | Всего пар с ≥ 1 заходом. |
repeatPairs | long | Да | countIf(visits > 1) | агрегат | Пары с возвратом (визитов > 1). |
repeatRate | decimal | Да | repeatPairs / viewerObjectPairs (guard 0) | — | Доля возвращавшихся пар, 0..1 (2 знака). |
avgVisitsPerPair | decimal | Да | avg(visits) | — | Среднее число заходов на пару (2 знака). |
maxVisitsPerPair | int | Да | max(visits) | — | Максимум заходов на один объект одним зрителем. |
buckets | IReadOnlyList<RepeatBucketModel> | Да | GROUP BY bucket | агрегат | Распределение по числу визитов. |
RepeatBucketModel
| Поле | Тип | Источник | Описание |
|---|
visitsFrom | int | нижняя граница | Включительно (напр. 1, 4). |
visitsTo | int? | следующая граница − 1 | null — последний открытый бакет (4+). |
label | string | вычислено | 1 / 2 / 3 / 4+. |
pairs | long | count() в бакете | Число пар «зритель × объект». |
share | decimal | pairs / viewerObjectPairs (guard 0) | Доля бакета, 0..1 (2 знака). |
Предусловия и согласованность данных
- Пользователь застройщика авторизован; есть permission
dev-stats.view; scope DeveloperId = актор
(INV-STA-1, fail-closed — ../../README.md).
- Метод читает сырьё
property_object_view_facts (агрегатные меры rollup не дают per-pair распределения),
окно ≤ 366 дней (cost-guard).
- Число заходов на пару «зритель × объект» дедуплено по
PropertyObjectViewId (INV-STA-3): open+closed
одного просмотра и at-least-once дубли считаются одним заходом.
- Ключ зрителя (
coalesce(UserId, AnonymousId)) используется только для группировки и наружу не выходит
(INV-STA-7). Окно свежести ≤ 5 мин (INV-STA-5); операция read-only (событий нет).
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь застройщика (роль developer) с правом
dev-stats.view. DeveloperId берётся из identity-claims актора; если клиент прислал
DeveloperId ≠ актор → отказ DEV_STATS_FORBIDDEN (403).
- Проверка входа. Период задан и корректен, окно ≤ 366 дней (чтение сырья);
VisitBucketEdges строго
возрастает, ≥ 1, ≤ 8; enum ViewerKind/ViewSource — из допустимого множества (неизвестное →
ValidationError); иерархия «сверху вниз».
- Что читаем. Детальное распределение частоты, которого агрегатные меры rollup не дают — читаем факты
property_object_view_facts в два уровня, dedup-tolerant без FINAL:
- заходы на пару:
GROUP BY viewerKey = coalesce(UserId, AnonymousId), PropertyObjectId →
uniq(PropertyObjectViewId) — заходы дедуплены по PropertyObjectViewId (open + closed одного
просмотра = один заход; at-least-once-дубли не растят счётчик);
- распределение над per-pair:
bucket = roundDown(visits, [visitBucketEdges]), GROUP BY bucket ORDER BY bucket, count(); параллельно count(), countIf(visits > 1), avg(visits),
max(visits) для headline.
Заданные уровни иерархии — префикс-скан по ключу (DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId, PropertyObjectId, ViewedAt, FactId) (группировка по PropertyObjectId попадает на компонент
ключа); диапазон дат режет партиции toYYYYMM(ViewedAt).
- Расчёты и маппинг. Для каждой границы
visitBucketEdges[i] — RepeatBucketModel:
visitsFrom = edges[i], visitsTo = edges[i+1] − 1 (или null для последнего открытого), label
(4+ для открытого), pairs (0, если пусто), share = pairs / viewerObjectPairs.
repeatRate = repeatPairs / viewerObjectPairs (guard 0, 2 знака); avgVisitsPerPair = avg(visits)
(2 знака). Пустая выборка → viewerObjectPairs = 0, бакеты с нулями. Персональные ключи (viewerKey)
используются только для группировки и в ответ не попадают (наружу — счётчики/доли).
- Результат. Возвращается
RepeatFrequencyResult (200). SLO чтения сырья — p95 ≤ 600 мс.
Недоступность ClickHouse → DEV_STATS_UNAVAILABLE (503).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Нет/некорректный период, окно > 366 дней, невалидные visitBuckets, неизвестный enum, иерархия «снизу вверх» | Ошибка валидации запроса | 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 с viewerObjectPairs = 0 и нулевыми бакетами. SLO чтения сырья —
p95 ≤ 600 мс.
Acceptance Criteria
- Распределение по частоте. Дано авторизованный застройщик и корректный период. Когда
GET /repeat-frequency без visitBuckets. Тогда 200 с бакетами 1/2/3/4+, суммой share ≈ 1,
repeatRate и avgVisitsPerPair.
- Дедуп заходов. Дано зритель открыл объект один раз, но пришли open+closed и повторная доставка. Когда
считаются визиты. Тогда пара учтена как
1 заход (INV-STA-3), а не 2–3.
- Повторный визит. Дано зритель заходил на объект в 3 разных просмотра. Когда считается распределение.
Тогда пара попадает в бакет
3, вносит вклад в repeatPairs и repeatRate.
- Пустая выборка. Дано период без просмотров. Когда запрос. Тогда
200, viewerObjectPairs = 0,
бакеты с нулями (не ошибка).
- Guard доли. Дано
viewerObjectPairs = 0. Когда считаются share/repeatRate. Тогда = 0 без
ошибки.
- Невалидные бакеты. Дано
visitBuckets=[2,1] (не по возрастанию), < 1 или > 8 значений. Когда
запрос. Тогда 400 ValidationError.
- Ключи зрителей не раскрываются. Дано любой ответ. Когда клиент читает результат. Тогда в нём только
счётчики/доли;
UserId/AnonymousId отсутствуют (INV-STA-7), в логах их тоже нет.
- Чужой scope / нет права. Дано чужой
DeveloperId или нет dev-stats.view. Когда запрос. Тогда
403 DEV_STATS_FORBIDDEN (INV-STA-1).
- Неизвестный enum / окно > 366 дней. Дано
viewerKind=vip либо окно шире лимита. Когда запрос. Тогда
400 ValidationError.
- Недоступность витрины. Дано ClickHouse недоступен. Когда запрос. Тогда
503 DEV_STATS_UNAVAILABLE
(retry с backoff).
Совместимость и наблюдаемость
- Optional-поля — additive; смена дефолтных
visitBuckets — семантически breaking для клиента
(согласовывать). Unknown enum → 400.
- Logs:
dev-stats-repeat-frequency-read (developerId, dateFrom/dateTo, viewerObjectPairs, repeatRate,
elapsed; PII нет — ключи зрителей не логируются). Metrics: dev_stats_repeat_frequency_requests_total,
latency histogram (SLO p95 ≤ 600 мс — сырьё). Traces: span приёма → span обработки → span чтения
ClickHouse; correlation — сквозной trace traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы