POST /api/developers/v1/promotions — Покупка пакета продвижения (без оплаты)
Назначение
Менеджер компании (застройщик или агентство/брокер — ADR-0052) покупает платформенный пакет продвижения
(promotion-package.md) для набора полиморфных целей
(Targets[] { TargetKind, TargetId }) — экран «Продвижение» ЛК. Набор допустимых
TargetKind ограничен PackageKind (INV-1): ProjectBoost → ровно 1 Project, PropertyObjectBoost →
1..MaxTargetsPerPurchase PropertyObject, PostBoost → 1..MaxTargetsPerPurchase Post. По решению
№10 (technical-assignment.md) оплата не реализуется: покупка создаётся и сразу активируется в
одной операции с PaymentStatus = NotRequired. Активная покупка публикует событие буста
(promotion-purchase-changed.md) — денормализацию в целевые
карточки выполняют консюмеры-потребители (catalog/news), не эта операция (ADR-0060). Покупку гейтит
feature flag promotion.enabled.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | promotion |
| API area | developers |
| Feature group | promotions |
| Статус | draft |
| Документ | docs/06-services/promotion/api/developers/purchase-promotion.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (менеджер компании-покупателя — JWT identity) |
| Auth policy / Permissions | permission promotion.purchases.manage в scope организации; feature flag promotion.enabled = true |
| Scope (RBAC) | организация; каждая цель обязана принадлежать компании актора (INV-4, проверка в контексте-владельце) |
| Record-level | не применяется (владение целью проверяет контекст-владелец) |
| Tenant isolation | TenantId = CompanyId покупателя (claims, ADR-0052); PromotionPurchase.TenantId = CompanyId; выборки покупок ограничиваются компанией актора |
| Audit | создание: отметка автора (CreatedByUserId) + Version = 1; покупки не удаляются (финансовая история — терминальные статусы) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/developers/v1/promotions |
| Success status | 201 |
| Idempotency header | не применяется (естественная идемпотентность: повтор упрётся в INV-5 PROMOTION_ALREADY_ACTIVE) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да | Required (= CompanyId) | tenant isolation |
PackageId | body | Guid | Да | != Guid.Empty; существование и Status=Active — шаг 2 (INV-3) | — |
Targets | body | PromotionTargetInput[] | Да | >= 1, distinct по (TargetKind, TargetId); кол-во и TargetKind — по PackageKind (INV-1); владение — шаг 2 (INV-4) | — |
Модель PromotionTargetInput
| Поле | Тип | Обязательность | Валидация | Описание |
|---|---|---|---|---|
TargetKind | string (enum PromotionTargetKind) | Да | Project / PropertyObject / Post; должен совпадать с допустимым для PackageKind | Тип продвигаемой цели. |
TargetId | Guid | Да | != Guid.Empty | Идентификатор цели в контексте-владельце. |
Модель ответа PromotionPurchaseResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PromotionPurchase.Id | — | Созданная покупка. |
Status | string (enum PromotionPurchaseStatus) | Да | PromotionPurchase.Status | — | Active (активирована в этой же операции). |
PaymentStatus | string (enum PromotionPaymentStatus) | Да | PromotionPurchase.PaymentStatus | — | NotRequired (решение №10). |
PackageKind | string (enum PromotionPackageKind) | Да | PromotionPurchase.PackageKind (снапшот) | — | ProjectBoost / PropertyObjectBoost / PostBoost. |
Targets | PromotionTargetModel[] | Да | PromotionPurchaseTarget.Target | — | Цели покупки ({ TargetKind, TargetId }). |
BoostWeight | int | Да | PromotionPurchase.BoostWeight (снапшот) | — | Вес буста покупки. |
StartsAt | DateTimeOffset | Да | PromotionPurchase.StartsAt | — | Момент активации. |
EndsAt | DateTimeOffset | Да | PromotionPurchase.EndsAt | — | StartsAt + DurationDays; job expire-promotions завершит. |
Version | long | Да | PromotionPurchase.Version | — | 1. |
Алгоритм
- Контекст и доступ. Операцию выполняет менеджер компании; tenant — его компания; требуется permission
promotion.purchases.manage. Feature-гейт: приpromotion.enabled = false→PROMOTION_DISABLED(409) до всякой загрузки. - Проверка входа.
PackageIdне пуст;Targetsнепуст, distinct по(TargetKind, TargetId), каждыйTargetIdне пуст → иначеValidationError. - Проверка предусловий (по загруженным данным).
- Пакет (INV-3 PackagePurchasable): пакет существует и
Status = Active; иначе →PROMOTION_PACKAGE_NOT_AVAILABLE(недоступный пакет неотличим от несуществующего). - Цели и вид (INV-1 TargetsMatchKind): все
Targets[].TargetKindдопустимы дляKindпакета (таблица «Kind → TargetKind» — promotion-package.md); приProjectBoostровно 1 цель, приPropertyObjectBoost/PostBoost—1..MaxTargetsPerPurchase; иначе →PROMOTION_TARGETS_INVALID. - Владение (INV-4 TargetOwnership): каждая цель принадлежит компании покупателя — проверяется в
контексте-владельце (
Project/PropertyObject→catalog,Post→news) синхронным запросом резолвинга владельца; несовпадение/несуществование/недоступность (fail-closed) →PROMOTION_TARGET_NOT_OWNED. Контракт резолвинга — assumption (DSC-PRM-1). - Пересечение (INV-5 NoOverlapSameTarget): для каждой цели не должно быть покупки того же пакета на
ту же цель в статусе
Pending/Active(в рамках компании); иначе →PROMOTION_ALREADY_ACTIVE. Целевые карточки не загружаются — они в чужих контекстах; денормализация буста происходит у потребителей по событию (ADR-0060).
- Пакет (INV-3 PackagePurchasable): пакет существует и
- Создание и активация. Создать покупку со снапшотом пакета:
PackageKind/PackageCode/BoostWeight/Price(INV-2 SnapshotImmutable),Status = Pending,PaymentStatus = NotRequired, целиPromotionPurchaseTarget(INV-1), автор — актор,Version = 1. В той же операции активировать (Pending → Active, сразу, оплата не требуется):StartsAt = now,EndsAt = now + DurationDays. - Сохранение и событие. Сохранить покупку с целями и в одном согласованном изменении зафиксировать
событие
PromotionPurchaseChangedKafkaEvent(топикpin.promotion.purchase-changed.v1, key =PurchaseId) соStatus = Active; payload — из снапшотов покупки, контракт — promotion-purchase-changed.md. Пересчёта буста в промо-контексте нет (денормализация — у потребителейcatalog/news; такжеnotifications, будущий биллинг). Конкурентность двух одновременных покупок ловится проверкой INV-5 + уникальным ограничением целей. - Результат. Вернуть карточку покупки (
Status = Active,EndsAt,Version = 1) →201+Location: /api/developers/v1/promotions/{id}; ошибки → коды таблицы ниже.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | PromotionPurchaseChangedKafkaEvent → pin.promotion.purchase-changed.v1 (Status=Active), публикуется атомарно со сменой статуса |
| External calls | резолвинг владельца целей — internal read контекста-владельца (catalog/news, INV-4); feature flag — из конфигурации/claims локально |
| Cache / projections / search | none в этом контексте — 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 |
|---|---|---|---|---|
ValidationError | 400 | Пустой PackageId, пустой/дублирующийся Targets, пустой TargetId | Ошибка валидации запроса | no retry |
UNAUTHORIZED | 401 | Нет/просрочен JWT | Требуется авторизация | no retry |
FORBIDDEN | 403 | Нет permission promotion.purchases.manage | Доступ запрещён | no retry |
PROMOTION_DISABLED | 409 | Feature flag promotion.enabled = false | Покупка продвижения временно недоступна | no retry |
PROMOTION_PACKAGE_NOT_AVAILABLE | 409 | Пакет не найден или Status != Active (INV-3) | Пакет недоступен для покупки | no retry |
PROMOTION_TARGETS_INVALID | 422 | TargetKind/кол-во целей не соответствуют PackageKind (INV-1) | Проверьте выбор целей для пакета | no retry |
PROMOTION_TARGET_NOT_OWNED | 403 | Цель не принадлежит компании / недоступна в контексте-владельце (INV-4) | Цель не принадлежит вашей компании | no retry |
PROMOTION_ALREADY_ACTIVE | 409 | Есть Pending/Active покупка на (PackageId, цель) (INV-5) | Такое продвижение уже активно | no retry |
Совместимость и наблюдаемость
- Новые optional поля результата/события (в т.ч. будущие платёжные атрибуты
Pending → Paid, новыеTargetKind) — additive; удаление/переименование/смена типа, bumpContractVersionсобытия — breaking. Неизвестные значения enum вbody→ValidationError. - 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) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: PromotionPurchase
- API (3): GET /api/developers/v1/promotion-packages — Доступные пакеты продвижения, GET /api/developers/v1/promotions — Мои покупки продвижения, POST /api/developers/v1/promotions/{purchaseId}/cancel — Отмена покупки продвижения
- Use Cases (1): UC-PRM-001. Покупка продвижения (компания)
- События (1): Event Contract: pin.promotion.purchase-changed (PromotionPurchaseChangedKafkaEvent)
- scenarios (1): UC-OVR-009. Продвижение поста / объекта недвижимости
- registries (1): Реестр API-методов PIN
- index.md (1): Promotion Service (Pin.Promotion)
Связанные документы
- Сущность и событие: promotion-purchase.md
(
PromotionPurchase/PromotionPurchaseTarget, INV-1..6, снапшоты), пакет — promotion-package.md; контракт события — promotion-purchase-changed.md. - Соседние операции: list-promotion-packages.md (доступные пакеты), list-my-promotions.md (мои покупки), cancel-promotion.md (отмена); admin CRUD пакетов — ../admin/create-promotion-package.md.
- Решение №10 technical-assignment.md (оплата отложена); ADR-0052 (tenancy = CompanyId), ADR-0060 (полиморфный таргет, консюмеры буста).