Entity / Aggregate: PropertyObjectViewStats
Дом полей агрегата-счётчика «Статистика просмотров объекта недвижимости» контекста user-activity (модуль views).
Проекции (поля «популярность» в catalog, ответы API) ссылаются сюда и перечисляют только дельту.
Назначение
PropertyObjectViewStats — инкрементальный агрегат по объекту недвижимости: сколько раз и как долго объект недвижимости
просматривался за всё время. Обновляется консюмером собственного события
pin.user-activity.property-object-view-ended.v1 (ADR-0056 п.3), а не триггерами и не онлайн-подсчётом по
property_object_views — так retention сырых просмотров (12 мес) не портит накопленную статистику.
Разграничение с dev-stats (ADR-0057): здесь — простые суммарные счётчики на объект недвижимости в PostgreSQL
(операционные, «сколько всего/в среднем»); многомерная аналитика застройщика (по датам, зрителям,
иерархии проект→здание→планировка, 3 года) — контекст dev-stats на ClickHouse.
| Metadata | Значение |
|---|---|
| Owning team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст user-activity, таблица user_activity.property_object_view_stats |
| Change context | greenfield |
Классификация
| Поле | Значение |
|---|---|
| Контекст | user-activity |
| Kind | Entity (anemic-проекция/счётчик; не AggregateRoot — доменных событий и инвариантного поведения нет) |
| Source of truth | user-activity (производная от потока property-object-view-ended; пересобираема заново из dev-stats/событий при потере) |
| Таблица | user_activity.property_object_view_stats |
| Tenant isolation | не tenanted — платформенный агрегат по объекту недвижимости без владельца (ADR-0052 п.3); доступ ограничен permissions |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK = property_object_id (одна строка на объект недвижимости, INV-LVS-001); логическая ссылка на объект недвижимости chessboard/catalog — без FK | событие property-object-view-ended (PropertyObjectId) | Идентификатор объекта недвижимости. |
ViewsCount | long | Да | bigint, default 0, check chk_property_object_view_stats_views_nonneg (views_count >= 0) | консюмер: +1 на каждое property-object-view-ended | Сколько раз объект недвижимости просматривался (закрытые просмотры). |
TotalDuration | TimeSpan | Да | PG interval, default '0', check chk_property_object_view_stats_duration_nonneg | консюмер: + DurationSeconds события | Суммарная длительность всех просмотров объекта недвижимости. |
AvgDuration | TimeSpan | — (вычисляемое) | не хранится: TotalDuration / ViewsCount (0 при ViewsCount = 0) — вычисляется на чтении | derived | Средняя длительность просмотра. |
LastViewedAt | DateTimeOffset | Да | ix_property_object_view_stats_last_viewed btree(last_viewed_at desc) — выборки «недавно смотрели» | консюмер: greatest(текущее, event.LastActivityAt) (INV-LVS-004) | Момент окончания последнего просмотра объекта недвижимости. |
UpdatedAt | DateTimeOffset | Да | — | консюмер (now()) | Последнее обновление строки. |
Value objects
VO не используются: все поля скалярные.
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
PropertyObjectViewStats → PropertyObject (chessboard/catalog) | логическая reference (кросс-контекст, без FK) | Id = PropertyObjectId | display-данные объекта недвижимости добирает потребитель (каталог) | — (удалённый/скрытый объект недвижимости сохраняет статистику) |
PropertyObjectViewStats ← PropertyObjectView | производность (не FK) | PropertyObjectView.PropertyObjectId | инкремент консюмером property-object-view-ended, батчем при чтении списков | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-LVS-001 SingleRowPerPropertyObject | Ровно одна строка на PropertyObjectId | PK = property_object_id; консюмер пишет upsert (INSERT ... ON CONFLICT (id) DO UPDATE) | — |
| INV-LVS-002 MonotonicCounters | ViewsCount/TotalDuration только растут; инкремент — атомарный SQL-UPDATE (views_count = views_count + 1, total_duration = total_duration + delta), не read-modify-write в памяти | консюмер; check-констрейнты | — |
| INV-LVS-003 IdempotentIncrement | Одно событие инкрементирует не более одного раза: бизнес-идемпотентность по ключу PropertyObjectViewId, отметка ключа — в одном согласованном изменении с инкрементом | консюмер PropertyObjectViewEndedConsumer | — (повтор — no-op, ack) |
| INV-LVS-004 LastViewedAtMonotonic | LastViewedAt = greatest(текущее, event.LastActivityAt) — события могут прийти вне порядка | консюмер (SQL greatest) | — |
| INV-LVS-005 DerivedNotStored | AvgDuration не хранится (иначе рассинхрон при инкрементах) — вычисляется на чтении | чтение | — |
| INV-LVS-006 SystemWriterOnly | Пишет только консюмер property-object-view-ended (system actor); публичных мутаций нет | отсутствие command-API | — |
Диаграмма состояний неприменима: поля Status нет, строка создаётся первым событием и далее только
инкрементируется.
Алгоритм консюмера-инкремента (PropertyObjectViewEndedConsumer, дельта — полный док ../consumers/property-object-view-ended.md)
Consumer собственного топика pin.user-activity.property-object-view-ended.v1:
- Приём и идемпотентность. По ключу
property-object-view-stats:{PropertyObjectViewId}проверить, обработано ли событие: если да → ack без изменений (INV-LVS-003). - Проверка входа. При несовпадении
ContractVersion— warn-and-proceed;DurationSeconds < 0→ лог ошибки контракта, событие отбраковывается (poison → DLQ по политике консюмера). Существование объекта недвижимости не проверяется (INV-LV-006 upstream). - Инкремент счётчика по
PropertyObjectId(атомарный upsert без предварительного чтения): нет строки → создатьViewsCount = 1,TotalDuration = DurationSeconds,LastViewedAt = LastActivityAt; строка есть →ViewsCount += 1,TotalDuration += DurationSeconds,LastViewedAt = greatest(LastViewedAt, LastActivityAt);UpdatedAt = now(). - Согласованность. Инкремент и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается после сохранения.
- Результат. Успех → ack; инфраструктурная ошибка → retry по политике консюмера.
Окно согласованности и восстановление
- Freshness для
catalog: инкремент отстаёт от закрытия просмотра на цепочкуactivity-closer→ (Kafkaproperty-object-view-ended) →PropertyObjectViewEndedConsumer(SLA инкремента < 1 мин). В этом окне бейдж/сортировка популярности вcatalogмогут не учитывать самый последний просмотр; расхождение самоустраняется после обработки события (монотонность гарантирует, что счётчик не «откатится»). - Пересборка при потере/повреждении: агрегат производный — восстанавливается полным replay-ем топика
property-object-view-ended(или из фактовdev-stats). Так как идемпотентность инкремента ведётся по ключуproperty-object-view-stats:{PropertyObjectViewId}(INV-LVS-003), чистая пересборка выполняется в сброшенном пространстве этих ключей — иначе повторно проигрываемые события будут отброшены как уже применённые, и счётчики останутся пустыми.
Доменные и интеграционные события
Не публикует ни доменных, ни интеграционных событий (anemic-счётчик, конец цепочки обработки).
Источник инкрементов — PropertyObjectViewEndedKafkaEvent (дом: property-object-view.md).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK btree(id); ix_property_object_view_stats_last_viewed btree(last_viewed_at desc) |
| Партиционирование / retention | Нет: ≤ 1 строка на объект недвижимости (сотни тысяч строк максимум), хранится бессрочно — переживает retention сырых property_object_views |
| Concurrency | Атомарный SQL-инкремент; блокировок не требуется |
| Permissions / PII | PII нет (агрегат без зрителей). Чтение: internal m2m (catalog — сортировка/бейджи популярности), permission user-activity.stats.view (Менеджер ПИН / администратор платформы). Публичного API на сырые счётчики нет — публикацию «N просмотров» решает catalog |
| Audit | UpdatedAt; полная воспроизводимость из потока property-object-view-ended (replay топика/dev-stats) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: PropertyObjectView, Entity / Aggregate: Session
- API (5): GET /api/internal/user-activity/property-objects/view-stats — Батч платформенных счётчиков просмотров объектов недвижимости, GET /api/internal/user-activity/users/{userId}/property-object-activity — Активность инвестора по объектам недвижимости (карточка лида CRM), GET /api/internal/user-activity/users/{userId}/property-objects/view-stats — Батч просмотров инвестора по объектам недвижимости (обогащение карточек CRM), POST /api/user-activity/property-object-views — Открыть просмотр объекта недвижимости, PUT /api/user-activity/property-object-views/{id}/heartbeat — Продление активности просмотра объекта недвижимости
- Use Cases (2): UC-ACT-002. Трекинг длительности и числа просмотров объекта недвижимости, UC-ACT-004. Агент/менеджер видит активность инвестора (для карточки лида)
- События (1): Event Contract: pin.user-activity.property-object-view-ended (PropertyObjectViewEndedKafkaEvent)
- Консюмеры (1): Consumer: User-Activity Popularity (pin-catalog-popularity)
- Фоновые задачи (4): Background Job: ActivityCloserJob, Background Job: ActivityRetentionJob, Background Job: LeadExpirationJob, Background Job: PopularityRecomputeJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): user-activity Service
Связанные документы
- property-object-view.md — источник события
property-object-view-ended(payload там); session.md — механизм закрытия. - ../README.md; будущий док консюмера: ../consumers/property-object-view-ended.md.
- Решения: ADR-0054 (модуль
views), ADR-0056 п.3 (инкрементальный консюмер), ADR-0057 (разграничение сdev-stats), ADR-0052 п.3 (платформенные данные).