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

POST /api/developers/v1/promotions — Покупка пакета продвижения (без оплаты)

Назначение

Менеджер компании (застройщик или агентство/брокер — ADR-0052) покупает платформенный пакет продвижения (promotion-package.md) для набора полиморфных целей (Targets[] { TargetKind, TargetId }) — экран «Продвижение» ЛК. Набор допустимых TargetKind ограничен PackageKind (INV-1): ProjectBoost → ровно 1 Project, PropertyObjectBoost1..MaxTargetsPerPurchase PropertyObject, PostBoost1..MaxTargetsPerPurchase Post. По решению №10 (technical-assignment.md) оплата не реализуется: покупка создаётся и сразу активируется в одной операции с PaymentStatus = NotRequired. Активная покупка публикует событие буста (promotion-purchase-changed.md) — денормализацию в целевые карточки выполняют консюмеры-потребители (catalog/news), не эта операция (ADR-0060). Покупку гейтит feature flag promotion.enabled.

Metadata

ПолеЗначение
Сервис/контекстpromotion
API areadevelopers
Feature grouppromotions
Статусdraft
Документdocs/06-services/promotion/api/developers/purchase-promotion.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (менеджер компании-покупателя — JWT identity)
Auth policy / Permissionspermission promotion.purchases.manage в scope организации; feature flag promotion.enabled = true
Scope (RBAC)организация; каждая цель обязана принадлежать компании актора (INV-4, проверка в контексте-владельце)
Record-levelне применяется (владение целью проверяет контекст-владелец)
Tenant isolationTenantId = CompanyId покупателя (claims, ADR-0052); PromotionPurchase.TenantId = CompanyId; выборки покупок ограничиваются компанией актора
Auditсоздание: отметка автора (CreatedByUserId) + Version = 1; покупки не удаляются (финансовая история — терминальные статусы)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/developers/v1/promotions
Success status201
Idempotency headerне применяется (естественная идемпотентность: повтор упрётся в INV-5 PROMOTION_ALREADY_ACTIVE)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired (= CompanyId)tenant isolation
PackageIdbodyGuidДа!= Guid.Empty; существование и Status=Active — шаг 2 (INV-3)
TargetsbodyPromotionTargetInput[]Да>= 1, distinct по (TargetKind, TargetId); кол-во и TargetKind — по PackageKind (INV-1); владение — шаг 2 (INV-4)

Модель PromotionTargetInput

ПолеТипОбязательностьВалидацияОписание
TargetKindstring (enum PromotionTargetKind)ДаProject / PropertyObject / Post; должен совпадать с допустимым для PackageKindТип продвигаемой цели.
TargetIdGuidДа!= Guid.EmptyИдентификатор цели в контексте-владельце.

Модель ответа PromotionPurchaseResult

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаPromotionPurchase.IdСозданная покупка.
Statusstring (enum PromotionPurchaseStatus)ДаPromotionPurchase.StatusActive (активирована в этой же операции).
PaymentStatusstring (enum PromotionPaymentStatus)ДаPromotionPurchase.PaymentStatusNotRequired (решение №10).
PackageKindstring (enum PromotionPackageKind)ДаPromotionPurchase.PackageKind (снапшот)ProjectBoost / PropertyObjectBoost / PostBoost.
TargetsPromotionTargetModel[]ДаPromotionPurchaseTarget.TargetЦели покупки ({ TargetKind, TargetId }).
BoostWeightintДаPromotionPurchase.BoostWeight (снапшот)Вес буста покупки.
StartsAtDateTimeOffsetДаPromotionPurchase.StartsAtМомент активации.
EndsAtDateTimeOffsetДаPromotionPurchase.EndsAtStartsAt + DurationDays; job expire-promotions завершит.
VersionlongДаPromotionPurchase.Version1.

Алгоритм

  1. Контекст и доступ. Операцию выполняет менеджер компании; tenant — его компания; требуется permission promotion.purchases.manage. Feature-гейт: при promotion.enabled = falsePROMOTION_DISABLED (409) до всякой загрузки.
  2. Проверка входа. PackageId не пуст; Targets непуст, distinct по (TargetKind, TargetId), каждый TargetId не пуст → иначе ValidationError.
  3. Проверка предусловий (по загруженным данным).
    • Пакет (INV-3 PackagePurchasable): пакет существует и Status = Active; иначе → PROMOTION_PACKAGE_NOT_AVAILABLE (недоступный пакет неотличим от несуществующего).
    • Цели и вид (INV-1 TargetsMatchKind): все Targets[].TargetKind допустимы для Kind пакета (таблица «Kind → TargetKind» — promotion-package.md); при ProjectBoost ровно 1 цель, при PropertyObjectBoost/PostBoost1..MaxTargetsPerPurchase; иначе → PROMOTION_TARGETS_INVALID.
    • Владение (INV-4 TargetOwnership): каждая цель принадлежит компании покупателя — проверяется в контексте-владельце (Project/PropertyObjectcatalog, Postnews) синхронным запросом резолвинга владельца; несовпадение/несуществование/недоступность (fail-closed) → PROMOTION_TARGET_NOT_OWNED. Контракт резолвинга — assumption (DSC-PRM-1).
    • Пересечение (INV-5 NoOverlapSameTarget): для каждой цели не должно быть покупки того же пакета на ту же цель в статусе Pending/Active (в рамках компании); иначе → PROMOTION_ALREADY_ACTIVE. Целевые карточки не загружаются — они в чужих контекстах; денормализация буста происходит у потребителей по событию (ADR-0060).
  4. Создание и активация. Создать покупку со снапшотом пакета: PackageKind/PackageCode/BoostWeight/ Price (INV-2 SnapshotImmutable), Status = Pending, PaymentStatus = NotRequired, цели PromotionPurchaseTarget (INV-1), автор — актор, Version = 1. В той же операции активировать (Pending → Active, сразу, оплата не требуется): StartsAt = now, EndsAt = now + DurationDays.
  5. Сохранение и событие. Сохранить покупку с целями и в одном согласованном изменении зафиксировать событие PromotionPurchaseChangedKafkaEvent (топик pin.promotion.purchase-changed.v1, key = PurchaseId) со Status = Active; payload — из снапшотов покупки, контракт — promotion-purchase-changed.md. Пересчёта буста в промо-контексте нет (денормализация — у потребителей catalog/news; также notifications, будущий биллинг). Конкурентность двух одновременных покупок ловится проверкой INV-5 + уникальным ограничением целей.
  6. Результат. Вернуть карточку покупки (Status = Active, EndsAt, Version = 1) → 201 + Location: /api/developers/v1/promotions/{id}; ошибки → коды таблицы ниже.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsPromotionPurchaseChangedKafkaEventpin.promotion.purchase-changed.v1 (Status=Active), публикуется атомарно со сменой статуса
External callsрезолвинг владельца целей — internal read контекста-владельца (catalog/news, INV-4); feature flag — из конфигурации/claims локально
Cache / projections / searchnone в этом контексте — PromotionBoost карточек пересчитывают консюмеры catalog/news (ADR-0060)
Notificationsопосредованно: notifications — консюмер pin.promotion.purchase-changed.v1 (уведомление об активации)

Кросс-сервисные ожидания и окно консистентности

  • Ожидает от catalog/news (синхронно, INV-4): резолвинг владельца по (TargetKind, TargetId) возвращает признак «цель существует и принадлежит TenantId покупателя». Гарантия — fail-closed: любой иной исход (не найдена, чужая, контекст недоступен/таймаут) трактуется как «не владеет» → PROMOTION_TARGET_NOT_OWNED. Контракт резолвинга не финализирован (DSC-PRM-1).
  • Предоставляет потребителям (асинхронно): PromotionPurchaseChangedKafkaEvent (Status = Active, key = PurchaseId, ContractVersion = 1) со снапшотом BoostWeight/Targets/EndsAt — контракт promotion-purchase-changed.md.
  • Что видит пользователь в промежутке: покупка становится Active и видна в «Моих покупках» сразу (source of truth — этот контекст). Буст на карточке проекта/объекта (catalog) и на посте (news) проявляется после обработки события потребителем — окно eventual-consistency, целевой SLA < 1 мин. Между активацией и пересчётом у потребителя карточка ещё показывается без нового вклада — это ожидаемое промежуточное состояние, не ошибка.
  • Конкурентность: две одновременные покупки на одну цель — одна фиксируется, вторая проигрывает по проверке INV-5 и уникальному ограничению целей → PROMOTION_ALREADY_ACTIVE; двойного буста не возникает.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Пустой PackageId, пустой/дублирующийся Targets, пустой TargetIdОшибка валидации запросаno retry
UNAUTHORIZED401Нет/просрочен JWTТребуется авторизацияno retry
FORBIDDEN403Нет permission promotion.purchases.manageДоступ запрещёнno retry
PROMOTION_DISABLED409Feature flag promotion.enabled = falseПокупка продвижения временно недоступнаno retry
PROMOTION_PACKAGE_NOT_AVAILABLE409Пакет не найден или Status != Active (INV-3)Пакет недоступен для покупкиno retry
PROMOTION_TARGETS_INVALID422TargetKind/кол-во целей не соответствуют PackageKind (INV-1)Проверьте выбор целей для пакетаno retry
PROMOTION_TARGET_NOT_OWNED403Цель не принадлежит компании / недоступна в контексте-владельце (INV-4)Цель не принадлежит вашей компанииno retry
PROMOTION_ALREADY_ACTIVE409Есть Pending/Active покупка на (PackageId, цель) (INV-5)Такое продвижение уже активноno retry

Совместимость и наблюдаемость

  • Новые optional поля результата/события (в т.ч. будущие платёжные атрибуты Pending → Paid, новые TargetKind) — additive; удаление/переименование/смена типа, bump ContractVersion события — breaking. Неизвестные значения enum в bodyValidationError.
  • Logs: promotion-purchased (purchaseId, tenantId, packageId, packageKind, targetsCount, boostWeight); metrics: promotion_operations_total{operation="purchase-promotion"}, duration; traces — сквозной trace через traceparent, отдельный span на резолвинг владельца целей. Dashboard promotion-overview.

Расхождения контрактов (реестр)

#РасхождениеСтатусДействие
DSC-PRM-1Резолвинг владельца цели (INV-4) требует internal read-контракта у catalog (Project/PropertyObject) и news (Post) — контракт не финализированоткрытоСогласовать internal ownership-резолвинг с владельцами catalog/news; до согласования — fail-closed (PROMOTION_TARGET_NOT_OWNED)

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

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

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