UC-PRM-002. Администратор управляет пакетами продвижения
Назначение
Описывает сценарий жизненного цикла платформенного пакета продвижения (PromotionPackage):
администратор платформы через admin API заводит пакет (черновик), правит его поля, публикует его на витрину
застройщика и снимает с продажи (архивация). Пакет — платформенная сущность каталога монетизации (не
tenanted); он определяет вид продвижения, вес буста, длительность и прейскурантную цену будущих покупок.
Оплата не реализуется (решение №10) — цена информационная. Связан с контекстом promotion.
| Поле | Значение |
|---|
| ID | UC-PRM-002 |
| Контекст | promotion (см. docs/06-services/promotion/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | Администратор платформы (через admin API — ADR-0053) |
| Permissions / scope | promotion.packages.admin, policy AdminSystemOnly; scope — платформа |
| Tenant | null (пакеты не tenanted — ADR-0052 п.3) |
| Record-level | не применяется |
Предусловие
- Admin-система аутентифицирована m2m client-credentials (
pin-admin-system), передаёт X-Admin-User-Id.
- Актор имеет permission
promotion.packages.admin в scope платформы (policy AdminSystemOnly); иначе
операция отклоняется до бизнес-логики (FORBIDDEN, при отсутствии/просрочке m2m-токена — UNAUTHORIZED).
- Для правки/публикации/архивации целевой пакет существует и не архивирован: архивный (soft-deleted)
пакет неотличим от несуществующего (
PROMOTION_PACKAGE_NOT_FOUND).
- Мутации сопровождаются заголовком
Idempotency-Key (admin API ретраит по таймауту); для оптимистической
защиты правки может передаваться ExpectedVersion.
- Контекст запуска не привязан к компании (пакет — каталог платформы).
Триггер
| Поле | Значение |
|---|
| Источник | действие администратора → admin API promotion |
| Триггерящий элемент | раздел «Пакеты продвижения» → «Создать/Изменить/Опубликовать/Архивировать» |
Основной Поток
- Просмотр каталога. Админ открывает раздел «Пакеты продвижения» и запрашивает управленческий список
(
GET /api/admin/v1/promotion-packages) — пакеты всех статусов (Draft/Active/Archived, архив —
по правилу видимости) с аудит-проекцией и счётчиком действующих покупок ActivePurchasesCount (чтобы
видеть черновики и последствия архивации). Витрина застройщика этих данных не видит.
- Создание черновика. Админ создаёт пакет (
POST /api/admin/v1/promotion-packages: Code, Name,
Kind, BoostWeight, DurationDays, MaxTargetsPerPurchase, Price). Система проверяет форматы и
диапазоны полей, кросс-поле MaxTargetsPerPurchase ⇔ Kind (INV-2 пакета) и уникальность Code среди
живых пакетов (INV-1); при успехе пакет создаётся в статусе Draft ([*] → Draft, Version = 1) и
невидим покупателям — покупка невозможна. Ответ 201 + PromotionPackageResult (Status = Draft).
- Правка полей. Админ меняет
Name/Description/BoostWeight/DurationDays/MaxTargetsPerPurchase/
Price без смены статуса (PUT /api/admin/v1/promotion-packages/{id}, целевой Status = текущему).
Code и Kind в контракт правки не входят и неизменяемы (INV-3); присланные и отличные от
сохранённых → отказ. Version инкрементируется. Правка действующего (Active) пакета применяется только
к будущим покупкам — действующие покупки держат снапшот (INV-2 покупки), событий пересчёта нет.
- Публикация. Админ переводит черновик в продажу (
PUT, Status = Active; переход Draft → Active по
матрице). Пакет становится виден и покупаем немедленно на витрине застройщика (GET /promotion-packages
отдаёт только Active); лага нет — source of truth в этом контексте.
- Снятие с продажи (архивация). Админ архивирует пакет (
DELETE /api/admin/v1/promotion-packages/{id},
симметрично PUT со Status = Archived): soft-delete (Status = Archived, отметка DeletedAt/
DeletedByUserId). Пакет пропадает с витрины; действующие покупки доживают срок и истекают штатно
(FK restrict, INV-2 снапшота покупки) — архив пакета их не трогает.
- Ответ и аудит. Каждая мутация отвечает
201/200 с PromotionPackageResult (при архиве — с
ArchivedAt) и фиксируется в глубоком аудите (актор X-Admin-User-Id, packageId/Code, диф полей,
переход статуса, результат). Повтор мутации с тем же Idempotency-Key возвращает ранее сохранённый
результат без повторного применения.
Инварианты, статусы и валидации
| Инвариант | Условие | Нарушение |
|---|
| INV-1 CodeUnique | Code уникален среди живых пакетов (deleted_at is null) | PROMOTION_PACKAGE_CODE_DUPLICATE |
| INV-2 MaxTargetsMatchesKind | MaxTargetsPerPurchase задан ⇔ Kind ∈ {PropertyObjectBoost, PostBoost}; null при ProjectBoost | PROMOTION_PACKAGE_MAX_TARGETS_REQUIRED / _NOT_APPLICABLE (create) · PROMOTION_PACKAGE_MAX_TARGETS_RULE (update) |
| INV-3 ImmutableIdentity | Code и Kind неизменяемы после создания | PROMOTION_PACKAGE_IMMUTABLE_FIELD |
| INV-4 StatusTransition | переходы Status только по матрице ниже | PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION |
| INV-5 CurrencyRef | Price.CurrencyId существует в справочнике валют и не архивен (internal resolve handbook); допустима только RUB (ADR-0048) | PROMOTION_CURRENCY_UNKNOWN (неизвестный/архивный id); не-RUB → ValidationError |
| INV-2 покупки (SnapshotImmutable) | правка/архив пакета не меняют уже созданные покупки (снапшот BoostWeight/Price/PackageKind) | — (событий пересчёта нет) |
| Из \ В | Draft | Active | Archived |
|---|
Draft | ✅ правка полей | ✅ публикация | ✅ архив черновика |
Active | ⛔ unpublish не поддержан → PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION | ✅ правка полей | ✅ архивация (покупки доживают срок) |
Archived | ⛔ терминально (soft-deleted → PROMOTION_PACKAGE_NOT_FOUND) | ⛔ | ⛔ |
Инициатор всех переходов — администратор (create/update/delete). Эффект: Draft — невидим покупателям;
Active — покупаем на витрине немедленно; Archived — снят с продажи, действующие покупки истекают штатно.
Валидации полей (границы)
Code — pattern ^[a-z0-9][a-z0-9-]*$, ≤ 64; Name 1..256 (после trim); Description ≤ 2000;
Kind ∈ {ProjectBoost, PropertyObjectBoost, PostBoost}; BoostWeight 1..100; DurationDays 1..365;
MaxTargetsPerPurchase 1..500 (условно по INV-2); Price.Amount decimal(14,2) >= 0 (0 — бесплатный
пакет); Price.CurrencyId — ссылка на справочник валют handbook (default RUB; резолв через internal
resolve, неизвестный/архивный id → PROMOTION_CURRENCY_UNKNOWN; допустима только RUB — ADR-0048).
Нарушение формата/диапазона → ValidationError.
Межсервисные последовательности и контракты
- Исходящих интеграционных событий операция не публикует. Единственный produced-контракт
promotion —
PromotionPurchaseChangedKafkaEvent (pin.promotion.purchase-changed.v1) — эмитят переходы покупок,
а не пакетов. Правка/публикация/архив пакета не порождают ни доменных, ни Kafka-событий и не затрагивают
потребителей буста (catalog/news).
- Видимость на витрине застройщика (внутри контекста). Витрина (
GET /promotion-packages, developers
API) читает source of truth того же контекста и отдаёт только Status = Active — окна
eventual-consistency нет: Draft → Active делает пакет покупаемым сразу после фиксации, → Archived —
сразу убирает с витрины. Управленческий список (admin) видит все статусы.
- Ожидания от соседей. Синхронно операция ожидает валидный доступ от Identity (permission
promotion.packages.admin, m2m-клиент pin-admin-system) и internal resolve справочника валют
handbook при валидации Price.CurrencyId (INV-5; недоступность справочника — fail-closed
PROMOTION_HANDBOOK_UNAVAILABLE). Целей покупок и объектов чужих контекстов операция не читает
(они появляются лишь при покупке пакета).
- Развязка с действующими покупками. Архив пакета не каскадит на покупки (FK
restrict); их снапшот
(INV-2 покупки) гарантирует, что буст действующих покупок пересчитывается потребителями только по событиям
истечения/отмены самих покупок, а не по правкам пакета.
Альтернативные Потоки
A1. Дубль кода пакета
Code уже существует среди живых пакетов (pre-check или гонка двух одновременных создателей, пойманная
уникальным ограничением каталога) → PROMOTION_PACKAGE_CODE_DUPLICATE (409). Сменить Code.
A2. Недопустимый переход статуса
- Целевой
Status вне матрицы (напр. Active → Draft — unpublish не поддержан; любой переход из
Archived) → PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION (409). Обновить состояние формы.
A3. Правка неизменяемого поля
- Присланный
Code/Kind, отличный от сохранённого → PROMOTION_PACKAGE_IMMUTABLE_FIELD (400). Убрать
поле из запроса.
A4. Несогласованный лимит целей
- Create:
Kind ∈ {PropertyObjectBoost, PostBoost} без MaxTargetsPerPurchase →
PROMOTION_PACKAGE_MAX_TARGETS_REQUIRED (400); Kind = ProjectBoost с заданным MaxTargetsPerPurchase
→ PROMOTION_PACKAGE_MAX_TARGETS_NOT_APPLICABLE (400); значение вне 1..500 → ValidationError (400).
- Update: нарушение того же правила INV-2 →
PROMOTION_PACKAGE_MAX_TARGETS_RULE (400).
A5. Пакет не найден или уже архивирован
- Правка/публикация/архив по
PackageId, которого нет или который soft-deleted →
PROMOTION_PACKAGE_NOT_FOUND (404). Архивный неотличим от несуществующего.
A6. Конкурентная правка двумя админами
- Передан
ExpectedVersion, не совпавший с текущей Version пакета (или конфликт оптимистической
блокировки) → CONCURRENCY_CONFLICT (409). Проигравший перечитывает пакет и повторяет с тем же
Idempotency-Key; победитель — тот, чья фиксация прошла первой.
A7. Идемпотентный повтор
- Повтор мутации с тем же
Idempotency-Key (ретрай admin API по таймауту) → ранее сохранённый результат
(201/200), без повторного создания/правки, без второй записи аудита-мутации.
A8. Архивация пакета с действующими покупками
- Архив пакета, на который ссылаются
Active-покупки, проходит успешно (Status = Archived): покупки не
каскадируются (FK restrict), доживают срок и истекают штатно job-ом expire-promotions. Событий по
покупкам архив не публикует.
Постусловие
- Пакет заведён/изменён/опубликован/архивирован;
Version инкрементирован; при архиве заполнены DeletedAt/
DeletedByUserId и Status = Archived.
- Актуальность витрины застройщика соответствует новому
Status немедленно (source of truth, без лага):
опубликованный пакет покупаем, архивный — недоступен.
- Действующие покупки не изменены (INV-2 снапшота); потребители буста не затронуты.
- Запись глубокого аудита мутации сделана; интеграционные и доменные события не публиковались.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|
ValidationError | 400 | Формат/диапазон/enum поля (Code/Name/BoostWeight/DurationDays/MaxTargetsPerPurchase/Price/Kind/Status) | Поправить форму |
PROMOTION_PACKAGE_MAX_TARGETS_REQUIRED / _NOT_APPLICABLE (create) / _RULE (update) | 400 | MaxTargetsPerPurchase не согласован с Kind (INV-2) | Поправить форму |
PROMOTION_PACKAGE_IMMUTABLE_FIELD | 400 | Правка Code/Kind (INV-3) | Убрать поле |
PROMOTION_CURRENCY_UNKNOWN | 422 | Price.CurrencyId не найден в справочнике валют или архивен (INV-5) | Исправить валюту |
UNAUTHORIZED | 401 | Нет/просрочен m2m-токен или X-Admin-User-Id | Обновить доступ |
FORBIDDEN | 403 | Клиент не pin-admin-system / нет promotion.packages.admin | Проверить доступ |
PROMOTION_PACKAGE_NOT_FOUND | 404 | Пакет отсутствует или архивирован (правка/публикация/архив) | Перечитать список |
PROMOTION_PACKAGE_CODE_DUPLICATE | 409 | Дубль Code среди живых (INV-1) | Сменить код |
PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION | 409 | Переход вне матрицы (INV-4) | Обновить состояние |
CONCURRENCY_CONFLICT | 409 | Гонка ExpectedVersion/оптимистической блокировки | Перечитать и повторить (тот же Idempotency-Key) |
PROMOTION_HANDBOOK_UNAVAILABLE | 503 | Справочник недоступен при резолве Price.CurrencyId (fail-closed) | Повторить (тот же Idempotency-Key) |
Отказоустойчивость и идемпотентность
- Идемпотентность: ключ —
Idempotency-Key (Guid, обязателен для мутаций). Повтор возвращает
сохранённый результат; двойного создания/правки и второй аудит-записи мутации нет.
- Конкурентность (version-guard): правка «двух админов» разрешается по
ExpectedVersion + оптимистической
блокировке — побеждает первая зафиксированная мутация, проигравший получает CONCURRENCY_CONFLICT и
перечитывает актуальную Version.
- Гонка на уникальность
Code: одновременные создания одного кода отсекаются уникальным ограничением
каталога — второй получает PROMOTION_PACKAGE_CODE_DUPLICATE.
- Компенсаций/саг нет: операция затрагивает единственный агрегат
PromotionPackage, эффект атомарен,
внешних сайд-эффектов и событий нет — откатывать нечего.
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
| — | пакет доменных событий не поднимает (v1) | — |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
| — | — | изменения пакета интеграционных событий не эмитят (Kafka-события есть только у покупок) | — |
Наблюдаемость и НФТ
- Аудит: каждая мутация (create/update/publish/archive) — в глубоком аудите: актор
X-Admin-User-Id,
packageId/Code, диф полей, переход from → to, результат. Чтение управленческого списка — read-sensitive
лог.
- Метрики/логи:
promotion_admin_operations_total{operation} (create/update/archive/list-promotion-packages),
duration; логи admin-promotion-package-created/updated. Без PII и без значения Price в логах (в логе
правки — только набор изменённых полей).
- Нагрузка/латентность: операции админ-редкие (единичные вызовы), объём каталога пакетов мал — целевая
латентность в пределах обычного API-бюджета; тяжёлых зависимостей нет.
- Доступ/безопасность: только admin API (m2m
pin-admin-system, permission promotion.packages.admin),
fail-closed при отсутствии доступа; Price — прейскурант, не секрет.
Acceptance Criteria
- AC1. Создание черновика. Дано валидный запрос создания с уникальным
Code. Когда админ вызывает
POST. Тогда пакет создаётся в Draft (Version = 1), невидим на витрине застройщика, ответ 201.
- AC2. Публикация открывает к покупке. Дано
Draft-пакет. Когда админ переводит его Draft → Active.
Тогда пакет немедленно виден и покупаем на витрине (GET /promotion-packages отдаёт Active), без лага.
- AC3. Архив не трогает действующие покупки. Дано
Active-пакет с действующими покупками. Когда админ
архивирует его. Тогда Status = Archived, пакет пропадает с витрины, а покупки доживают срок (INV-2
снапшота, FK restrict) и не получают событий.
- AC4. Неизменяемость идентичности. Дано существующий пакет. Когда в правке присланы отличные
Code/
Kind. Тогда отказ PROMOTION_PACKAGE_IMMUTABLE_FIELD (400), пакет не изменён.
- AC5. Недопустимый переход. Дано
Active-пакет. Когда запрошен Active → Draft. Тогда отказ
PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION (409).
- AC6. Правило лимита целей. Дано
Kind = ProjectBoost с заданным MaxTargetsPerPurchase (или
multi-target Kind без него). Когда создаётся пакет. Тогда отказ PROMOTION_PACKAGE_MAX_TARGETS_NOT_APPLICABLE/
_REQUIRED (400) по INV-2.
- AC7. Идемпотентность и аудит. Дано мутация с
Idempotency-Key. Когда она повторяется по таймауту.
Тогда возвращается тот же результат без второго применения; исходная мутация — в глубоком аудите.
- AC8. Конкурентная правка. Дано два админа правят один пакет. Когда оба присылают
ExpectedVersion.
Тогда первая фиксация побеждает, вторая получает CONCURRENCY_CONFLICT (409) и перечитывает Version.
- AC9. Никаких событий. Дано любая мутация пакета. Когда она зафиксирована. Тогда
pin.promotion.purchase-changed.v1
не публикуется и потребители буста не затронуты.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы