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

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 teamPIN Engagement
Статус документаdraft
Область действияконтекст user-activity, таблица user_activity.property_object_view_stats
Change contextgreenfield

Классификация

ПолеЗначение
Контекстuser-activity
KindEntity (anemic-проекция/счётчик; не AggregateRoot — доменных событий и инвариантного поведения нет)
Source of truthuser-activity (производная от потока property-object-view-ended; пересобираема заново из dev-stats/событий при потере)
Таблицаuser_activity.property_object_view_stats
Tenant isolationне tenanted — платформенный агрегат по объекту недвижимости без владельца (ADR-0052 п.3); доступ ограничен permissions

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPK = property_object_id (одна строка на объект недвижимости, INV-LVS-001); логическая ссылка на объект недвижимости chessboard/catalog — без FKсобытие property-object-view-ended (PropertyObjectId)Идентификатор объекта недвижимости.
ViewsCountlongДаbigint, default 0, check chk_property_object_view_stats_views_nonneg (views_count >= 0)консюмер: +1 на каждое property-object-view-endedСколько раз объект недвижимости просматривался (закрытые просмотры).
TotalDurationTimeSpanДаPG interval, default '0', check chk_property_object_view_stats_duration_nonnegконсюмер: + DurationSeconds событияСуммарная длительность всех просмотров объекта недвижимости.
AvgDurationTimeSpan— (вычисляемое)не хранится: TotalDuration / ViewsCount (0 при ViewsCount = 0) — вычисляется на чтенииderivedСредняя длительность просмотра.
LastViewedAtDateTimeOffsetДаix_property_object_view_stats_last_viewed btree(last_viewed_at desc) — выборки «недавно смотрели»консюмер: greatest(текущее, event.LastActivityAt) (INV-LVS-004)Момент окончания последнего просмотра объекта недвижимости.
UpdatedAtDateTimeOffsetДаконсюмер (now())Последнее обновление строки.

Value objects

VO не используются: все поля скалярные.

Модели JSONB

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
PropertyObjectViewStats → PropertyObject (chessboard/catalog)логическая reference (кросс-контекст, без FK)Id = PropertyObjectIddisplay-данные объекта недвижимости добирает потребитель (каталог)— (удалённый/скрытый объект недвижимости сохраняет статистику)
PropertyObjectViewStats ← PropertyObjectViewпроизводность (не FK)PropertyObjectView.PropertyObjectIdинкремент консюмером property-object-view-ended, батчем при чтении списков

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-LVS-001 SingleRowPerPropertyObjectРовно одна строка на PropertyObjectIdPK = property_object_id; консюмер пишет upsert (INSERT ... ON CONFLICT (id) DO UPDATE)
INV-LVS-002 MonotonicCountersViewsCount/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 LastViewedAtMonotonicLastViewedAt = greatest(текущее, event.LastActivityAt) — события могут прийти вне порядкаконсюмер (SQL greatest)
INV-LVS-005 DerivedNotStoredAvgDuration не хранится (иначе рассинхрон при инкрементах) — вычисляется на чтениичтение
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:

  1. Приём и идемпотентность. По ключу property-object-view-stats:{PropertyObjectViewId} проверить, обработано ли событие: если да → ack без изменений (INV-LVS-003).
  2. Проверка входа. При несовпадении ContractVersion — warn-and-proceed; DurationSeconds < 0 → лог ошибки контракта, событие отбраковывается (poison → DLQ по политике консюмера). Существование объекта недвижимости не проверяется (INV-LV-006 upstream).
  3. Инкремент счётчика по PropertyObjectId (атомарный upsert без предварительного чтения): нет строки → создать ViewsCount = 1, TotalDuration = DurationSeconds, LastViewedAt = LastActivityAt; строка есть → ViewsCount += 1, TotalDuration += DurationSeconds, LastViewedAt = greatest(LastViewedAt, LastActivityAt); UpdatedAt = now().
  4. Согласованность. Инкремент и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается после сохранения.
  5. Результат. Успех → ack; инфраструктурная ошибка → retry по политике консюмера.

Окно согласованности и восстановление

  • Freshness для catalog: инкремент отстаёт от закрытия просмотра на цепочку activity-closer → (Kafka property-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 / PIIPII нет (агрегат без зрителей). Чтение: internal m2m (catalog — сортировка/бейджи популярности), permission user-activity.stats.view (Менеджер ПИН / администратор платформы). Публичного API на сырые счётчики нет — публикацию «N просмотров» решает catalog
AuditUpdatedAt; полная воспроизводимость из потока property-object-view-ended (replay топика/dev-stats)

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

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

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

  • 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 (платформенные данные).