Consumer: User-Activity Actions (crm-user-activity-actions)
Один документ на два входных топика user-activity, наполняющих ленту событий лида (LeadEventEntity).
Контракты входных событий — дом property-object-view.md
и action-event.md; целевой агрегат — lead.md.
Назначение
Consumer group crm-user-activity-actions потребляет активность инвестора на витрине и, если у инвестора
есть активный лид, дописывает в его ленту типизированные события: PropertyObjectViewed (из
property-object-view-ended), FilterApplied / FavoriteAdded (из action-tracked). Так агент видит «что
делает и чего хочет клиент» (UC-1..3), не запрашивая user-activity синхронно. Если активного лида у
инвестора нет — событие не относится ни к какому лиду: no-op ack (лента не создаётся вне лида). Консюмер
собственных Kafka-событий не публикует (лента — внутреннее состояние crm).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| Consumer group | crm-user-activity-actions |
| Producer | user-activity (источник истины входных контрактов) |
| Топик 1 | pin.user-activity.property-object-view-ended.v1 — PropertyObjectViewEndedKafkaEvent |
| Топик 2 | pin.user-activity.action-tracked.v1 — ActionRecordedKafkaEvent (см. Change context) |
| ContractVersion | ожидается 1; при несовпадении версии события — warn-and-proceed |
| Эффект (событие → что меняется) | Дописывает в ленту активного лида LeadEventEntity (PropertyObjectViewed/FilterApplied/FavoriteAdded) |
Маппинг «событие → эффект» (тип события в ленте лида, LeadEventType):
| Входное событие | Условие | Тип события лида (LeadEventType) |
|---|---|---|
PropertyObjectViewEndedKafkaEvent | — | PropertyObjectViewed |
ActionRecordedKafkaEvent | ActionType = ApplyFilter | FilterApplied |
ActionRecordedKafkaEvent | ActionType = AddToFavorites | FavoriteAdded |
ActionRecordedKafkaEvent | ActionType ∈ {Search, ViewPropertyObject, RemoveFromFavorites, AddToSelection} | нет отображения → no-op ack |
README контекста и lead.md фиксируют топик как
pin.user-activity.action-tracked.v1; дом события action-event.md
публикует ActionRecordedKafkaEvent на канонический pin.user-activity.action-recorded.v1. Здесь принято
имя из README crm (owning-сервис консюмера); расхождение имён топика/типа — assumption, требует
reconciliation с user-activity перед реализацией. ViewPropertyObject намеренно не мапится — просмотры приходят
отдельным топиком property-object-view-ended; AddToSelection — консюмером selection-shared.
Контекст события
- Из метаданных события доступны: идентификатор сообщения
MessageId(для дедупликации),CreatedAt, версия контракта, идентификатор агрегата-источника (PropertyObjectViewId/ActionEventId). - Актор — системный контекст консюмера (не пользовательский); запись в ленту идёт в tenant найденного
лида (
TenantId = AgencyCompanyId). - Корреляция сквозного трейса восстанавливается из метаданных события.
- Бизнес-идемпотентность — по ключу
crm-user-activity-actions:{LeadEventType}:{MessageId}; страховка — uniqueux_lead_events_source_message_idнаLeadEventEntity.SourceMessageId = MessageId(INV ниже).
Входные данные / payload события
Ниже — только поля, которые consumer реально читает, и их маппинг в LeadEventEntity (дом типов/ограничений
payload лид-событий — lead.md, раздел «Enum LeadEventType и typed payload»).
PropertyObjectViewEndedKafkaEvent → LeadPropertyObjectViewedPayload (Type = PropertyObjectViewed)
| Поле payload события | Тип | Обяз. | Как используется (маппинг) |
|---|---|---|---|
UserId | Guid? | Нет | Ключ резолва активного лида (PrimaryInvestorUserId); null (гостевой просмотр) → no-op ack |
PropertyObjectId | Guid | Да | → LeadPropertyObjectViewedPayload.PropertyObjectId |
FilterSnapshot.Project.ProjectId | Guid? | Нет | → LeadPropertyObjectViewedPayload.ProjectId (null, если фильтра/проекта не было) |
DurationSeconds | int (>=0) | Да | → LeadPropertyObjectViewedPayload.DurationSeconds |
SessionId | Guid | Да | → LeadPropertyObjectViewedPayload.SessionId |
ViewedAt | DateTimeOffset | Да | → LeadEventEntity.OccurredAt |
ActionRecordedKafkaEvent → LeadFilterAppliedPayload (ApplyFilter → Type = FilterApplied)
| Поле payload события | Тип | Обяз. | Как используется (маппинг) |
|---|---|---|---|
UserId | Guid? | Нет | Резолв активного лида (PrimaryInvestorUserId); null → no-op ack |
OccurredAt | DateTimeOffset | Да | → LeadEventEntity.OccurredAt |
FilterSnapshot.City.CityId | long? | Нет | → LeadFilterAppliedPayload.CityId (ref-модель CityRefModel; id справочника городов pin.handbook) |
FilterSnapshot.City.CityName | string? | Нет | → LeadFilterAppliedPayload.CityName (display-имя из ref-модели как есть, без резолва в справочнике — мягкая семантика активности) |
FilterSnapshot.Developer.DeveloperId | Guid? | Нет | → LeadFilterAppliedPayload.DeveloperCompanyId (ref-модель DeveloperRefModel) |
FilterSnapshot.Project.ProjectId | Guid? | Нет | → LeadFilterAppliedPayload.ProjectId (ref-модель ProjectRefModel) |
FilterSnapshot.Price | NullableRange<decimal> | Нет | границы Price.Min / Price.Max → payload Price (NullableRange<decimal>) |
FilterSnapshot.Rooms | List<int>? | Нет | → RoomsCount = единственное значение при Count == 1, иначе null (лента показывает конкретную комнатность; мультивыбор не детализируется) |
FilterSnapshot (display) | AppliedFilterModel | Да | → RawQuery — денормализованное резюме фильтра (город/застройщик/проект/цена/комнаты) из *Name-полей ref-моделей, чтобы лента читалась без lookup в catalog |
FilterApplied снижена дебаунсомApplyFilter приходит из user-activity редко: на витрине каталога кнопки «Найти» нет, фронт трекает
применение фильтра отдельным запросом с trailing-дебаунсом 5000–7000 мс
(track-action.md, action-event.md), поэтому в топик попадает
только устоявшийся фильтр, а не каждое изменение контрола. Как следствие лента лида получает осмысленные
FilterApplied без флуда — дополнительного троттлинга на стороне crm не требуется.
ActionRecordedKafkaEvent → LeadFavoriteAddedPayload (AddToFavorites → Type = FavoriteAdded)
| Поле payload события | Тип | Обяз. | Как используется (маппинг) |
|---|---|---|---|
UserId | Guid? | Нет | Резолв активного лида; null → no-op ack |
OccurredAt | DateTimeOffset | Да | → LeadEventEntity.OccurredAt |
PropertyObjectId | Guid? | Да (для AddToFavorites) | → LeadFavoriteAddedPayload.PropertyObjectId |
Алгоритм
Оба входных события различаются только шагами 1 и 4 (приведение к нормализованному событию ленты); шаги 2–3, 5–6 — общая обработка.
- Контекст события. Восстановить метаданные события (идентификатор сообщения, время, версия
контракта); при несовпадении версии — warn-and-proceed. Проверить идемпотентность по ключу
crm-user-activity-actions:{LeadEventType}:{MessageId}: дубль → пропустить (ack). Дляaction-tracked— диспетчеризация поActionType; неотображаемый тип →skipped-unmapped-action, ack. - Проверка входа.
SourceMessageIdзадан; дляPropertyObjectViewed—DurationSeconds >= 0; дляFilterApplied—FilterSnapshotсодержит ≥ 1 заполненное поле; дляFavoriteAdded—PropertyObjectIdзадан. Нарушение контракта payload (обяз. поле отсутствует) →CRM_ACTIVITY_INVALID_PAYLOAD(non-retriable → DLQ). ЕслиUserId == null— резолв активного лида невозможен → no-op ack (см. шаг 3). - Что читаем. Найти активный лид инвестора (
PrimaryInvestorUserId,Status = Active; резолв по событийномуUserId). Если активного лида нет → no-op ack (skipped-no-active-lead) — активность вне лида в ленту не пишется. При нескольких активных лидах (мульти-агентство) выбор детерминирован поLastEventAt desc(см. Assumptions). - Правила и маппинг поле-в-поле. Построить typed payload лид-события (таблицы выше) и дописать в ленту
через метод агрегата: создаётся
LeadEventEntity(Type,Payload,OccurredAtиз события,SourceMessageId = MessageId,TenantId = activeLead.TenantId,CreatedAt). Guard INV-LEAD-2 (Status = Active, иначеCRM_LEAD_NOT_ACTIVE); метод также ставитactiveLead.LastEventAt = OccurredAtи поднимает доменное событиеLeadEventAdded. - Что меняется. Появляется строка ленты и обновляется
LastEventAtлида. Запись события ленты и отметка идемпотентности фиксируются в одном согласованном изменении; интеграционных событий консюмер не публикует (лента — внутреннее состояниеcrm,LeadEventAddedостаётся доменным). Offset подтверждается только после успешной фиксации. - Результат. Успех → ack. Retriable-код → retry по политике; non-retriable → DLQ + алерт. No-op-ветки
(нет
UserId, нет активного лида, неотображаемыйActionType, дубль) — тоже ack.
Диаграмма
Идемпотентность и replay
- На уровне консюмера: ключ
crm-user-activity-actions:{Type}:{MessageId}. - На уровне хранилища: unique partial
ux_lead_events_source_message_idнаSourceMessageId— повтор доставки после сбоя ловится ограничением: конфликт уникальности → трактуется как дубль → ack (no-op). Глобальная уникальностьSourceMessageIdозначает одну строку ленты на одно исходное сообщение — событие адресуется одному лиду (детерминированный выбор при мульти-агентстве, см. Assumptions). - Replay: да. Повторный проход топиков безопасен (ключ идемпотентности + unique
SourceMessageId); runbook —crm/runbooks/.
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
| Нарушение контракта payload (обяз. поле отсутствует) | CRM_ACTIVITY_INVALID_PAYLOAD | Нет (non-retriable) | Да + алерт | Исправить продьюсер user-activity → replay |
Неизвестный ActionType (additive у продьюсера) | — | Нет | Нет | Warn + no-op ack (forward-compatible); при необходимости добавить маппинг (релиз) |
Гонка SourceMessageId (unique violation) | — | Нет | Нет | Трактуется как дубль → ack |
Лид ушёл в Expired между шагами 3 и 5 | CRM_LEAD_NOT_ACTIVE | Нет | Нет | No-op ack (активность истёкшему лиду не пишется) |
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/skipped-unmapped-action/skipped-no-investor/skipped-no-active-lead/appended/dead-letteredс полямиMessageId,AggregateId,LeadEventType,LeadId(при наличии). PII в payload нет (id и агрегированные величины;CityName/DeveloperName/ProjectName— display-справочники, не PII). - Metrics:
crm_activity_consume_duration_ms,crm_activity_appended_total{type},crm_activity_skipped_total{reason},crm_activity_retries_total,crm_activity_dlq_total,crm_activity_lag_seconds(now −envelope.CreatedAt). - Traces: span приёма события → span обработки → spans обращений к хранилищу; корреляция из метаданных события.
- Alerts: лаг > 5 мин (SLO README); DLQ > 0; всплеск
skipped-no-active-lead(возможен разрыв привязки лидов). - Безопасность: consumer под system-actor; запись в tenant найденного лида (
AgencyCompanyId), кросс-tenant-запись невозможна по построению (адресация через сам лид). Топикиuser-activity— платформенные (ADR-0052 п.2), доступ — m2m ACL Kafka. Инвестор не видит ленты; лента — данные агента/лида (permissionscrm.leads.viewна чтение, см. lead.md).
Assumptions и ограничения
- Топик
action-trackedvsaction-recorded— расхождение имён (см. Change context), reconciliation обязателен перед реализацией. - Мульти-агентство: INV-CRM-001 допускает несколько активных лидов у одного инвестора (разные агенты); из-за
глобально-уникального
SourceMessageIdстрока ленты пишется одному лиду — выбор детерминирован поLastEventAt desc. Fan-out одной активности в несколько лент — отдельное решение (вне scope). - Авто-продление лида по активности клиента (
Lead.LastExtendedAt, lead.md) в этом консюмере не реализуется — отдельная политика.
Acceptance Criteria
- AC-1 (просмотр объекта). Дано: у инвестора есть активный лид. Когда:
PropertyObjectViewEndedKafkaEventсUserId. Тогда: в ленту дописываетсяPropertyObjectViewed(LeadPropertyObjectViewedPayload),LastEventAt = OccurredAt, ack. - AC-2 (применён фильтр). Дано: активный лид есть. Когда:
ActionRecordedKafkaEventсActionType = ApplyFilter. Тогда: дописываетсяFilterAppliedс денормализованнымRawQuery, ack. - AC-3 (добавлено в избранное). Дано: активный лид есть. Когда:
ActionType = AddToFavoritesсPropertyObjectId. Тогда: дописываетсяFavoriteAdded, ack. - AC-4 (неотображаемый ActionType). Дано:
ActionType ∈ {Search, ViewPropertyObject, RemoveFromFavorites, AddToSelection}. Когда: событие. Тогда:skipped-unmapped-action, лента не меняется, ack. - AC-5 (гостевая активность). Дано:
UserId == null. Когда: событие. Тогда:skipped-no-investor, ack. - AC-6 (нет активного лида). Дано: у инвестора нет активного лида. Когда: событие. Тогда:
skipped-no-active-lead, лента не создаётся вне лида, ack. - AC-7 (дубль по сообщению). Дано: уже виденный
MessageId/SourceMessageId. Когда: повторная доставка/replay. Тогда: дубль (ux_lead_events_source_message_id) → no-op, ack. - AC-8 (нарушен контракт payload). Дано: отсутствует обязательное поле payload. Когда: событие. Тогда:
CRM_ACTIVITY_INVALID_PAYLOAD(non-retriable) → DLQ + алерт.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: LeadEntity
- API (1): GET /api/agent/crm/leads/{leadId}/events — Лента событий лида
- Use Cases (2): UC-ACT-004. Агент/менеджер видит активность инвестора (для карточки лида), UC-UAC-001. Агрегированная активность нескольких инвесторов-участников лида
- События (2): События dev-stats, Event Contract: pin.user-activity.property-object-view-ended (PropertyObjectViewEndedKafkaEvent)
- Консюмеры (2): Consumer: Favorites Events (UserActivityFavoriteAddedConsumer / ...RemovedConsumer), Consumer: Selections Events (UserActivitySelectionItemAddedConsumer)
Связанные документы
- Целевой агрегат и типы payload лид-событий: lead.md (INV-LEAD-2,
LeadEventType,LeadPropertyObjectViewedPayload/LeadFilterAppliedPayload/LeadFavoriteAddedPayload); родитель — client.md. - Входные контракты (дом полей): property-object-view.md
(
PropertyObjectViewEndedKafkaEvent), action-event.md (ActionRecordedKafkaEvent,AppliedFilterModel); соседний консюмер лид-ленты — selection-shared.md; карта событий — README контекста. - Шаблон: consumer-template.md.
- ADR: 0052 tenancy, 0056 simplicity-first.