Consumer: Identity Agent Membership Changed
Назначение
Consumer слушает событие изменения членства агента в агентстве (ADR-0061: вступление Active / выход
Left; ADR-0072: Reason/EffectiveAt) и обновляет в user-centric проекции
AgentPerformanceEntity состояние членства строки пары
(AgentUserId, CompanyId) — поля MembershipStatus + LeaveReason/LeftAt/EffectiveAt. При
мульти-членстве (ADR-0070) у агента несколько строк одновременно: обрабатывается только строка своего
CompanyId, прочие членства агента не трогаются. Накопленные (earned) метрики
сделки/фиксации/комиссии строки не изменяются: они user-centric и сохраняются при выходе из агентства
(ADR-0061 п.5, ADR-0070 п.6) — членство меняет лишь состояние строки, а не историю. Производный
display-статус («независимый» / «в агентстве N») вычисляется на чтении из набора строк агента (см.
agent-performance.md) — consumer его не хранит.
Consumer перечислен в контракте события identity
(agent-membership-changed.md, таблица потребителей).
Прекращение оснований комиссий текущего периода при Left (там же) реализуется штатным путём начислений
(начисление создаётся по завершённой сделке с иммутабельным снапшотом AgencyCompanyId, не по текущему
членству) — отдельной мутации здесь не требуется.
Идемпотентность — по идентификатору сообщения + монотонный guard по Version членства строки:
устаревшее/переставленное событие не откатывает актуальное состояние своей строки. Порядок per-agent (key =
AgentUserId) сохраняет причинную последовательность join/leave по каждому агентству агента.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | rewards (фоновый обработчик) |
| Обрабатываемое событие | pin.identity.agent-membership-changed |
| Topic | pin.identity.agent-membership-changed.v1 |
| Partitions / key | key = AgentUserId — строгий порядок join/leave per-agent (3 партиции, ADR-0056) |
| ContractVersion | 1; при несовпадении версии — warn-and-proceed (rewards_contract_mismatch_total) |
| Consumer group | pin-rewards-identity-membership |
| Producer | identity: accept-invitation / become-agent (join → Active), leave-agency / become-investor (leave → Left) |
| Эффект (событие → что меняется) | состояние членства строки пары (AgentUserId, CompanyId) в проекции AgentPerformanceEntity (MembershipStatus + LeaveReason/LeftAt/EffectiveAt); прочие строки агента не трогаются |
Контекст события
- Из метаданных события доступны: идентификатор сообщения (для дедупликации), время события,
Version, идентификатор и тип агрегата-источника (AggregateId = MembershipId,AggregateType = "AgentMembership"). - Корреляция сквозного трейса восстанавливается из метаданных события.
- Обработка — под системным актором вне tenant-контекста (platform-scope): проекция
rewards.agent_performanceне привязана к tenant (ADR-0061 п.5, ADR-0070 п.6). - Ключ идемпотентности —
rewards:identity-membership:{MessageId}; доменный guard —payload.Version > LastMembershipVersionстроки(AgentUserId, CompanyId).
Входные данные / payload события
Контракт — identity/events/agent-membership-changed.md. Ниже — поля, которые consumer реально читает.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
AgentUserId | Guid | Да | Часть ключа строки (Upsert по паре (AgentUserId, CompanyId)); = Kafka key |
CompanyId | Guid | Да | Часть ключа строки — агентство, состояние которого меняем; строка своего CompanyId, прочие не трогаются |
Status | string (enum AgentMembershipStatus: Active/Left) | Да | Active → MembershipStatus = Active (+ обнулить LeaveReason/LeftAt/EffectiveAt); Left → MembershipStatus = Left |
Reason | string? (enum LeaveReason: SelfLeave/ExcludedByAgency) | Нет | При Left → LeaveReason; unknown enum → мягкая деградация (warning, трактовать как состоявшийся выход) |
EffectiveAt | DateTimeOffset? | Нет | При Left → EffectiveAt (момент вступления выхода в силу; для отложенного увольнения ≠ LeftAt) |
Version | long | Да | Монотонный guard строки: применять только если > LastMembershipVersion строки (AgentUserId, CompanyId) |
LeftAt | DateTimeOffset? | Нет | При Left → LeftAt; LastActivityAt = max(..., LeftAt) |
JoinedAt | DateTimeOffset | Да | Момент вступления (для LastActivityAt при Active; FirstActivityAt ??= для новой строки) |
MembershipId | Guid | Да | Трассировка (AggregateId); в проекцию не пишется |
Поле InvitationId consumer-ом не читается. Reason/EffectiveAt — additive-поля (ADR-0072),
ContractVersion не меняется; при Status = Active они null.
Алгоритм
- Контекст события. Восстановить метаданные события (идентификатор сообщения, агрегат-источник, время);
сверить
ContractVersion— при несовпадении warn-and-proceed. Проверить идемпотентность по ключуrewards:identity-membership:{MessageId}: дубль доставки →skipped-duplicate, пропуск (ack). - Проверка входа.
AgentUserId != Guid.Empty;CompanyId != Guid.Empty(часть ключа строки — обязателен при любомStatus; пусто →REWARDS_MEMBERSHIP_EVENT_INVALID→ DLQ);Status ∈ {Active, Left}(unknown enum → warningunknown-membership-status, fail-closed: не считать активным, трактовать строку какLeft); приLeft—Reasonможет быть unknown → warningunknown-leave-reason, мягкая деградация (сохранить строкуLeft, пропустить причину-специфичную логику, ADR-0072). Существование сущностей не проверяется (проекция создаётся лениво). - Что читаем. Строку проекции по паре
(AgentUserId, CompanyId)(без tenant-фильтра); её отсутствие допустимо — тогда создаётся новая строка этого агентства с нулевыми метриками. Прочие строки агента не читаются и не меняются. - Бизнес-правила и переходы. Монотонный guard строки: если для существующей строки
payload.Version <= LastMembershipVersion— событие устаревшее,skipped-stale-membership, отметка идемпотентности, пропуск. Иначе применить к строке пары:Status = Active→MembershipStatus = Active,LeaveReason = LeftAt = EffectiveAt = null,FirstActivityAt ??= JoinedAt,LastActivityAt = max(LastActivityAt, JoinedAt);Status = Left→MembershipStatus = Left,LeaveReason = payload.Reason(если известен),LeftAt = payload.LeftAt,EffectiveAt = payload.EffectiveAt,LastActivityAt = max(LastActivityAt, LeftAt).LastMembershipVersion = payload.Version;Version += 1,UpdatedAt = now. Earned-счётчики и суммы строки не изменяются; строки других агентств агента не затрагиваются. - Что меняется. Одна строка проекции
AgentPerformanceEntityпары(AgentUserId, CompanyId)создаётся или обновляется. Конкурентная доставка в двух инстансах — проигравший перечитывает и повторяет guard (двойного применения нет). - Согласованность. Обновление проекции и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается после фиксации. В skip-ветках фиксируется только отметка идемпотентности.
- Результат. Успех → ack;
REWARDS_MEMBERSHIP_EVENT_INVALID→ DLQ + алерт; инфраструктурные сбои → retry (таблица ниже). Исходящих событий проекция не порождает.
Диаграмма
Идемпотентность и replay
- Consumer-level: отметка по идентификатору сообщения (
rewards:identity-membership:{MessageId}). - Business-level: монотонный guard по
LastMembershipVersionстроки — повтор/переупорядочивание не откатывает состояние; состояние каждой строки(AgentUserId, CompanyId)сходится к последней (maxVersion) записи членства этого агентства. - Replay: да. Реплей с offset 0 безопасен: применяется только событие с
Versionбольше сохранённого строкой; ключ =AgentUserIdгарантирует порядок per-agent (join/leave по каждому агентству в причинном порядке). Полный rebuild проекции — вместе с другими источниками (runbook../runbooks/rewards-agent-performance-rebuild.md, по конвенции).
Retry и DLQ
DLQ-топик группы: pin.rewards.identity-membership-dlq.v1.
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны) | — (exception) | Exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
Нарушение контракта (AgentUserId пуст; CompanyId пуст) | REWARDS_MEMBERSHIP_EVENT_INVALID | Нет (non-retriable) | Да + алерт | Разбор с командой identity; после фикса — replay из DLQ |
Unknown enum Status | — (не ошибка) | — | Нет | Warning, fail-closed Left (строка не активна); мониторинг rewards_membership_unknown_status_total |
Unknown enum Reason | — (не ошибка) | — | Нет | Warning unknown-leave-reason; строка Left без причины-специфичной логики (ADR-0072); мониторинг rewards_membership_unknown_reason_total |
| Устаревшее событие (guard) | — (skip) | — | Нет | Не требуется |
Acceptance-критерии
- Вступление (Active). Дано: событие
Status = ActiveсCompanyId,Version > LastMembershipVersionстроки. Когда: обработка. Тогда: строка(AgentUserId, CompanyId)—MembershipStatus = Active,LeaveReason/LeftAt/EffectiveAt = null,FirstActivityAt ??= JoinedAt,LastActivityAt = max(..., JoinedAt),LastMembershipVersion = Version; earned-метрики не меняются; ack. - Выход (Left) с Reason. Дано: событие
Status = Left,Reason = SelfLeave(илиExcludedByAgency),Versionновее сохранённой строкой. Когда: обработка. Тогда: строка своегоCompanyId—MembershipStatus = Left,LeaveReason= из события,LeftAt/EffectiveAtзаполнены; earned-метрики строки сохранены (ADR-0061 п.5, ADR-0070 п.6). - Мульти-членство: прочие членства не теряются. Дано: агент состоит в агентствах A и B (две строки
Active); приходитLeftпо A. Когда: обработка. Тогда: строка A →Left; строка B остаётсяActiveс прежними метриками; агрегат display-статуса — по-прежнемуInAgency(есть активная B). - Несколько Active подряд. Дано: агент получает
Activeпо A, затемActiveпо B (разныеCompanyId). Когда: обработка. Тогда: две строкиActiveодновременно (INV-AP5); ни одна не затирает другую. - Ленивое создание. Дано: строки пары ещё нет. Когда: обработка. Тогда: создаётся новая строка
(AgentUserId, CompanyId)с нулевыми метриками и состоянием членства из события. - Устаревшее/переставленное событие (guard). Дано:
Version <= LastMembershipVersionстроки. Когда: обработка. Тогда:skipped-stale-membership, состояние строки не откатывается, ack. - Дубль доставки. Дано: то же
MessageId. Когда: обработка. Тогда:skipped-duplicate, ack. - Unknown Status (fail-closed). Дано: значение
Statusвне{Active, Left}. Когда: обработка. Тогда: warningunknown-membership-status, строка трактуется какLeft(не активна), метрикаrewards_membership_unknown_status_total. - Unknown Reason (мягкая деградация). Дано:
Status = Leftс неизвестнымReason. Когда: обработка. Тогда: warningunknown-leave-reason, строкаLeft(сам выход состоялся), причина-специфичная логика пропущена, метрикаrewards_membership_unknown_reason_total. - Нарушение контракта. Дано: пустой
AgentUserIdили пустойCompanyId. Когда: обработка. Тогда:REWARDS_MEMBERSHIP_EVENT_INVALID→ DLQ + алерт. - Replay. Дано: re-read с offset 0. Когда: обработка. Тогда: для каждой строки применяется только событие
с max
Version; состояние членства каждого агентства сходится к последней записи.
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/skipped-stale-membership/membership-applied/dead-letteredс полямиMessageId,AgentUserId,CompanyId,Status,Reason,Version. PII/суммы отсутствуют (событие — только id + метки времени, класс Internal). - Metrics:
rewards_membership_consume_duration_ms,rewards_membership_applied_total{status},rewards_membership_skipped_stale_total,rewards_membership_unknown_status_total,rewards_membership_unknown_reason_total,rewards_identity_membership_lag_seconds,rewards_identity_membership_dlq_total. - Traces: span приёма события → span обработки → span обращения к хранилищу; корреляция из метаданных события.
- Alerts: lag consumer-group > 5 мин; DLQ > 0.
- Безопасность: system-actor, platform-scope (проекция вне tenant); tenant mismatch неприменим (нет
tenant-поля); payload — доверенный внутренний топик
identity, без untrusted-ввода.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Projection: AgentPerformanceEntity
- API (1): GET /api/v1/my/agent-performance — Моя личная статистика (агент, user-centric)
- Use Cases (1): UC-RWD-006. Агент смотрит личную статистику за всю карьеру (user-centric)
- События (1): Event Contract: pin.identity.agent-membership-changed (AgentMembershipChangedKafkaEvent)
- Консюмеры (2): Consumer: Agent Performance Projection, Consumer: CRM Fixation Approved
- index.md (1): Rewards Service
Связанные документы
- Проекция: agent-performance.md (поля строки,
MembershipStatus, guard поVersionстроки, INV-AP4/AP5/AP7). - Контракт события: identity/events/agent-membership-changed.md
(
Reason/EffectiveAt); сущность-источник — identity/domain/agent-membership.md. - Соседние consumer-ы проекции: agent-performance-projection.md, crm-fixation-approved.md.
- Use-case: UC-RWD-006; UC-IDN-010 (выход из агентства).
- Шаблон: consumer-template.md; ADR-0061 (п.5),
ADR-0070 (п.6 мульти-членство),
ADR-0072 (
LeaveReason/EffectiveAt), ADR-0052, ADR-0056.