Event Contract: pin.promotion.purchase-changed (PromotionPurchaseChangedKafkaEvent)
Версионируемый контракт интеграционного события «изменился статус покупки продвижения». Единственное
produced-событие контекста promotion (README, раздел «Events И Consumers»). Consumer-документы
ссылаются сюда, не переописывая payload.
Назначение
Фиксирует каждый переход статуса агрегата PromotionPurchase
(покупка пакета продвижения компанией): активацию без оплаты, отмену и истечение срока. Публичное: даёт
downstream-контекстам снапшот покупки без синхронного запроса в promotion — потребители буста
пересчитывают денормализованный вклад у себя (catalog для целей Project/PropertyObject,
news для Post), notifications шлёт покупателю уведомление об активации/истечении, будущий
биллинг (решение №10 — оплата отложена) получает заложенные поля цены. Переименован из
pin.catalog.promotions.v1 (ADR-0060: продвижение вынесено из catalog в отдельный контекст).
Metadata
| Поле | Значение |
|---|---|
| Event type | pin.promotion.purchase-changed |
| C# type | PromotionPurchaseChangedKafkaEvent |
| Topic | pin.promotion.purchase-changed.v1 (единственный produced-топик контекста) |
| Partitions / key | 3 партиции (низкий трафик продвижения) / key = PurchaseId (= PromotionPurchase.Id) — per-purchase порядок |
| ContractVersion | 1 |
| Producer | promotion: активация purchase-promotion, отмена cancel-promotion, истечение job expire-promotions |
| Статус | draft |
Событие несёт идентификатор сообщения (для дедупликации), время события и ссылку на агрегат
(AggregateId = PurchaseId, AggregateType = "PromotionPurchase"). Actor-ids в событие не входят.
Публикуется атомарно со сменой статуса покупки.
Payload PromotionPurchaseChangedKafkaEvent
Дом полей — entity: PromotionPurchase; здесь перечислено только то,
что реально в событии. Строгая типизация: enum сериализуются строкой, голого object/json нет; коллекция
целей — типизированная вложенная модель.
| Поле | Тип | Обязательность | Источник (поле агрегата) | Compatibility | Описание |
|---|---|---|---|---|---|
PurchaseId | Guid | Да | PromotionPurchase.Id | Immutable | Ключ агрегата (= Kafka key = AggregateId). |
TenantId | Guid | Да | PromotionPurchase.TenantId (= CompanyId) | Immutable | Компания-покупатель (ADR-0052). |
PackageId | Guid | Да | PromotionPurchase.PackageId | Immutable | Купленный пакет. |
PackageCode | string | Да | PromotionPurchase.PackageCode (снапшот) | Immutable | Стабильный код (project-boost-week) — для аналитики consumer-ов без lookup. |
PackageKind | string (enum PromotionPackageKind) | Да | PromotionPurchase.PackageKind (снапшот) | Additive only (новые значения) | ProjectBoost / PropertyObjectBoost / PostBoost. |
Targets | PromotionTargetModel[] | Да (>= 1) | коллекция PromotionPurchaseTarget.Target | Additive only | Полиморфные цели покупки (см. вложенную модель). |
Status | string (enum PromotionPurchaseStatus) | Да | PromotionPurchase.Status | Additive only | Pending / Active / Expired / Cancelled — новое зафиксированное состояние. |
PaymentStatus | string (enum PromotionPaymentStatus) | Да | PromotionPurchase.PaymentStatus | Additive only | Сейчас всегда NotRequired (решение №10); Pending/Paid/Refunded — заготовка эквайринга. |
BoostWeight | int | Да | PromotionPurchase.BoostWeight (снапшот) | Additive only | Вес буста покупки (INV-2 — не меняется правкой пакета). |
StartsAt | DateTimeOffset | Да | PromotionPurchase.StartsAt | Additive only | Начало действия (момент активации). |
EndsAt | DateTimeOffset | Да | PromotionPurchase.EndsAt | Additive only | Конец действия (StartsAt + DurationDays). |
Price | PromotionPriceSnapshot | Да | PromotionPurchase.Price (owned VO, снапшот) | Additive only | Снапшот цены для будущего биллинга: CurrencyId + CurrencySnapshot (snapshots.md). |
Version | long | Да | PromotionPurchase.Version | Immutable semantics | Доменная версия; монотонный guard у потребителей. |
Вложенная модель PromotionTargetModel
Проекция owned-VO PromotionTarget (дом — promotion-purchase.md).
Потребитель фильтрует цели по TargetKind: catalog берёт Project/PropertyObject, news — Post.
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|---|---|---|---|
TargetKind | string (enum PromotionTargetKind) | Да | Project / PropertyObject / Post | Тип продвигаемой цели. |
TargetId | Guid | Да | != Guid.Empty | Идентификатор цели в контексте-владельце (логическая ссылка). |
Вложенная модель PromotionPriceSnapshot
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|---|---|---|---|
Amount | decimal | Да | >= 0 (decimal(14,2); 0 — бесплатный пакет) | Прейскурантная сумма на момент покупки. |
CurrencyId | short | Да | ссылка на запись справочника валют handbook; допустима только RUB (ADR-0048) | Типизированная ссылка на валюту (PromotionPrice.CurrencyId). |
Currency | CurrencySnapshot | Да | дом модели — handbook/domain/snapshots.md | Display-снапшот валюты (IsoCode/Name/Sign), зафиксированный на момент публикации; отдельного строкового кода валюты payload не несёт. |
Поля агрегата, не входящие в payload: CancelReason (свободный текст — потенциально внутренние
заметки), CreatedByUserId/UpdatedByUserId (actor — PII, см. Безопасность), RowVersion.
Когда публикуется
На каждый переход статуса покупки, всегда в одном согласованном изменении со сменой статуса покупки.
| Условие (источник) | Переход состояния | Согласованность |
|---|---|---|
| Покупка без оплаты, purchase-promotion | Pending → Active (в no-payment режиме — в той же операции создания; публикуется со Status = Active, PaymentStatus = NotRequired) | смена статуса и событие — в одном согласованном изменении |
| Отмена покупателем/админом, cancel-promotion | Active → Cancelled / Pending → Cancelled (CancelReason задан, но в payload не входит) | то же |
| Истечение срока, job expire-promotions | Active → Expired (now() >= EndsAt, FOR UPDATE SKIP LOCKED) | то же |
Терминальные статусы Expired/Cancelled переходов не имеют (INV-6) — по покупке публикуется конечное
число событий. Порядок per-purchase гарантирован key-ом PurchaseId; потребители держат Version-guard.
Потребители (consumers)
Сверено с README (раздел «Events И Consumers») и service-boundaries.md.
| Consumer-контекст | Consumer | Side effects | Идемпотентность | Задержка (SLA) |
|---|---|---|---|---|
catalog | PromotionBoostConsumer | Для целей Project/PropertyObject пересчитывает PromotionBoost карточек проекта/объекта (сумма BoostWeight активных покупок); истечение/отмена снимают вклад — promotion-boost.md | MessageId + Version-guard | < 1 мин |
news | PromotionBoostConsumer (news) | Для целей Post наполняет Post.PromotionBoost (ADR-0060; дом — post.md) | MessageId + Version-guard | < 1 мин |
notifications | PromotionPurchaseChangedConsumer | Уведомление менеджеру компании: Active — «продвижение запущено» (EndsAt), Expired — «срок истёк», Cancelled — «отменено» | MessageId + Version-guard | < 5 мин |
billing (будущий) | BillingPromotionPurchaseChangedConsumer (планируется) | Выставление счёта по Price при включении эквайринга (решение №10) | MessageId | n/a (не реализован) |
Гарантии доставки и порядка
Единый свод гарантий, на которые опираются потребители (catalog/news/notifications/будущий billing):
- Доставка — at-least-once. Событие публикуется после фиксации перехода статуса; при сбое публикации
возможен повтор. Потребитель обязан дедуплицировать по идентификатору сообщения (
MessageId). - Порядок — per-purchase. Партиционирование по key =
PurchaseIdгарантирует порядок событий одной покупки внутри партиции. Между разными покупками порядок не гарантирован. - Защита от переупорядочивания/дублей —
Version-guard. Потребитель применяет событие, только если егоVersionбольше последнего применённого по этой покупке; запоздавшее/повторное событие сVersion <=уже применённого — игнорируется (буст не откатывается назад). - Ограниченность. По каждой покупке конечное число событий:
Active→ (CancelledилиExpired); терминальные статусы переходов не имеют (INV-6). «Бесконечного» потока по одной покупке нет. - Отсутствие потери при недоступности consumer-а. Событие сохраняется в топике; отставший потребитель доберёт его при восстановлении (в пределах retention топика), затем снимет/поднимет буст.
Версионирование и совместимость (реальность SDK)
ContractVersion = 1; при несовпадении версии события с ожидаемой consumer логирует warning и продолжает (upcasting/schema-migration нет). Топик версию не кодирует автоматически — по конвенции суффикс.v1.- Additive-only для minor: новые optional-поля с безопасным default — без bump
ContractVersion. - Breaking (удаление/переименование/смена типа/смысла) — новый
ContractVersion = 2+ новый топикpin.promotion.purchase-changed.v2+ dual-publish на период миграции + deadline миграции consumers + sunset.v1. - Unknown enum (
Status/PackageKind/PaymentStatus/TargetKind) — fallback: сохранить строку как есть + warning, не падать (новыйTargetKindдля нового потребителя — просто игнорируется чужими консюмерами).
Безопасность
| Поле | Класс данных | Правило |
|---|---|---|
PurchaseId, TenantId, PackageId, PackageCode, PackageKind, Targets, Status, PaymentStatus, BoostWeight, StartsAt, EndsAt, Version | Internal | include |
Price.Amount, Price.CurrencyId, Price.Currency (CurrencySnapshot) | Internal (коммерческая цена пакета, не секрет) | include; доступ к топику — m2m ACL Kafka (catalog/news/notifications/billing) |
CancelReason | Internal (свободный текст) | exclude — в payload не входит |
CreatedByUserId / UpdatedByUserId (actor) | PII | exclude — в payload не входит |
PII в событии нет: TenantId — идентификатор компании (CompanyId), не физлица; персональные
actor-ids исключены. Событие НЕ содержит secrets, tokens, credentials, платёжных реквизитов (Price —
прейскурант пакета, не карта/счёт) и unbounded вложений (Targets ограничен MaxTargetsPerPurchase).
Наблюдаемость публикации
Публикация наблюдается метриками очереди публикации (не отдельным producer-кодом):
- метрики: глубина/лаг очереди неопубликованных событий, счётчик опубликованных сообщений по топику
pin.promotion.purchase-changed.v1, размер DLQ; - dashboard
promotion-overview(README, «Runtime И Эксплуатация»); alert — DLQ > 0, рост лага публикации; - trace каждой публикации коррелируется с исходной операцией/job.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: PostReaction + проекция PostRankingSignals (Design), Entity / Aggregate: PromotionPackage, Entity / Aggregate: PromotionPurchase
- API (2): POST /api/developers/v1/promotions — Покупка пакета продвижения (без оплаты), POST /api/developers/v1/promotions/{purchaseId}/cancel — Отмена покупки продвижения
- Use Cases (2): UC-PRM-001. Покупка продвижения (компания), UC-PRM-003. Истечение срока покупки продвижения
- Консюмеры (3): Consumer: Promotion Boost (pin-catalog-promotion-boost), Consumer: Promotion Boost (pin-news-promotion-boost), Consumer: Promotion Events → Notifications (notifications-promotion-events)
- Фоновые задачи (1): Background Job: ExpirePromotionsJob
- scenarios (1): UC-OVR-009. Продвижение поста / объекта недвижимости
- registries (1): Реестр событий PIN
- index.md (1): Promotion Service (Pin.Promotion)
Связанные документы
- Дом полей и переходов: promotion-purchase.md (
PromotionPurchase, stateDiagram, INV-2/6, VOPromotionTarget/PromotionPrice); пакет — promotion-package.md. - Snapshot-модель валюты
CurrencySnapshot(дом): handbook/domain/snapshots.md; RUB-only — ADR-0048. - Источники (операции/job): purchase-promotion, cancel-promotion, expire-promotions.
- Потребители: catalog/consumers/promotion-boost.md;
news(Post.PromotionBoost, ADR-0060). - Контекст: promotion/README.md; ADR — ADR-0060.