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

PUT /api/admin/v1/promotion-packages/{packageId} — Изменение/публикация/архивация пакета (admin)

Назначение

Правка и архивация платформенного пакета продвижения (promotion-package.md): администратор платформы через admin API правит поля пакета (Name, Description, BoostWeight, DurationDays, MaxTargetsPerPurchase, Price) и/или переводит его по жизненному циклу PromotionPackageStatus: публикация Draft → Active (пакет становится виден и покупаем — ../developers/list-promotion-packages.md) и архивация → Archived (снят с продажи, soft-delete). Code и Kind неизменяемы (INV-3).

Ключевое ограничение — INV-2 SnapshotImmutable покупки (promotion-purchase.md): правки пакета не меняют уже созданные PromotionPurchase (те держат снапшот BoostWeight/Price/PackageKind), поэтому операция не публикует интеграционных событий и не затрагивает потребителей буста. Требуется глубокий аудит.

Архивация — симметричная операция DELETE /api/admin/v1/promotion-packages/{packageId} (дельта описана здесь): soft-delete пакета (DeletedAt/DeletedByUserId, Status = Archived) без body. Пакет снят с продажи; действующие покупки доживают срок (FK restrict).

Metadata

ПолеЗначение
Сервис/контекстpromotion
API areaadmin (ADR-0053)
Feature groupadmin-promotions
Статусdraft
Документdocs/06-services/promotion/api/admin/update-promotion-package.md

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

ПроверкаЗначение
Actor typeExternalSystem (администратор платформы; машинный доступ admin API — ADR-0053)
Auth policy / Permissionspolicy AdminSystemOnly; permission promotion.packages.admin (identity/domain/permission.md)
Scope (RBAC)платформа (пакеты не tenanted — ADR-0052 п.3)
Record-levelне применяется
Tenant isolationне применяется (пакет — платформенная сущность)
Auditобязателен: глубокий аудит админ-мутаций — актор (X-Admin-User-Id), packageId, диф полей, переход статуса; отметка автора правки (UpdatedByUserId)/(при архиве) автора архивации (DeletedByUserId)

HTTP-контракт

ПолеЗначение
MethodPUT (архив-дельта: DELETE, тот же route)
Route/api/admin/v1/promotion-packages/{packageId}
Success status200
Idempotency headerIdempotency-Key (Guid; обязателен)
Correlationсквозной trace через заголовок traceparent; X-Admin-User-Id (Guid админа) — обязателен

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

Типы/ограничения полей — дом promotion-package.md. Архив-дельта (DELETE) body не принимает.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PackageIdrouteGuidДа!= Guid.Empty; существование — шаг 2
NamebodystringДа1..256 после trim
Descriptionbodystring?Нет≤ 2000 символов
BoostWeightbodyintДа1..100
DurationDaysbodyintДа1..365
MaxTargetsPerPurchasebodyint?Условно1..500; обязателен при Kind ∈ {PropertyObjectBoost, PostBoost}, null при ProjectBoost (INV-2 пакета)
PricebodyPromotionPriceModelДаAmountdecimal(14,2) >= 0; CurrencyIdshort?, default RUB; резолв через internal resolve handbook (INV-5): неизвестный/архивный id → PROMOTION_CURRENCY_UNKNOWN; допустима только RUB (ADR-0048)
Statusbodystring (enum PromotionPackageStatus)Да∈ {Draft,Active,Archived}; переход — матрица ниже
ExpectedVersionbodylong?Нет>= 1; optimistic-check против PromotionPackage.Version (защита «два админа»), дополняет RowVersion
AdminUserIdheader X-Admin-User-IdGuidДа!= Guid.Emptyотметка автора
IdempotencyKeyheader Idempotency-KeyGuidДа!= Guid.Empty

Code и Kind в контракт обновления не входят (INV-3 неизменяемы). Если присланы и отличаются → PROMOTION_PACKAGE_IMMUTABLE_FIELD (400).

Матрица переходов Status (guard шага 2/4)

Из \ ВDraftActiveArchived
Draft✅ правка✅ публикация✅ архив черновика
Active⛔ (unpublish не поддержан)✅ правка✅ архивация
Archived⛔ терминальный (soft-deleted → NotFound)

Недопустимый переход → PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION (409).

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

Дом модели-результата мутации пакета (переиспользуется create-promotion-package.md).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаPromotionPackage.IdПакет.
CodestringДаPromotionPackage.CodeСтабильный код (неизменяем).
NamestringДаPromotionPackage.NameНазвание после правки.
Descriptionstring?НетPromotionPackage.DescriptionМаркетинговый текст.
Kindstring (enum PromotionPackageKind)ДаPromotionPackage.KindНеизменяем.
BoostWeightintДаPromotionPackage.BoostWeightВес буста.
DurationDaysintДаPromotionPackage.DurationDaysСрок действия покупки.
MaxTargetsPerPurchaseint?НетPromotionPackage.MaxTargetsPerPurchaseЛимит целей (multi-target Kind).
PricePromotionPriceModelДаPromotionPackage.Price (owned){ Amount: decimal, CurrencyId: short } — прейскурант (оплата не требуется — решение №10).
Statusstring (enum PromotionPackageStatus)ДаPromotionPackage.StatusНовый статус после перехода.
VersionlongДаPromotionPackage.VersionВерсия после мутации (Version++).
ArchivedAtDateTimeOffset?НетPromotionPackage.DeletedAtМомент архивации; null для Draft/Active.

Алгоритм

  1. Контекст и доступ. Операцию выполняет администратор платформы через admin API; требуется permission promotion.packages.admin. Пакет — платформенная сущность, к tenant не привязан. Повтор с тем же Idempotency-Key возвращает ранее сохранённый результат (200), без повторной правки.
  2. Проверка входа. Диапазоны/длины/enum: Name 1..256, Description ≤ 2000, BoostWeight 1..100, DurationDays 1..365, MaxTargetsPerPurchase 1..500, Price.Amount >= 0, Status ∈ enum → иначе ValidationError. Валюта (INV-5): не заданный Price.CurrencyId заменяется дефолтом RUB; заданный — резолвится в справочнике валют через internal resolve handbook (неизвестный/архивный id → PROMOTION_CURRENCY_UNKNOWN, 422), затем guard RUB-only (ADR-0048): другая валюта → ValidationError; справочник недоступен → fail-closed PROMOTION_HANDBOOK_UNAVAILABLE (503).
  3. Существование и предусловия. Найти пакет по PackageId (архивированный считается ненайденным); отсутствует → PROMOTION_PACKAGE_NOT_FOUND (404). Неизменяемость (INV-3): присланные Code/Kind, отличные от сохранённых → PROMOTION_PACKAGE_IMMUTABLE_FIELD (400). Правило лимита целей (INV-2 пакета): при Kind ∈ {PropertyObjectBoost, PostBoost} MaxTargetsPerPurchase задан, при ProjectBoost — отсутствует, иначе → PROMOTION_PACKAGE_MAX_TARGETS_RULE (400). Переход (текущий Status → целевой Status) допустим по матрице, иначе → PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION (409). При заданном ExpectedVersion, не совпавшем с текущей версией пакета → CONCURRENCY_CONFLICT (409). Архивация (DELETE): целевой Status = Archived подставляется автоматически; тот же чеклист. Действующие покупки не читаются — INV-2: правка/архив пакета их не касается (покупки держат снапшот, доживают срок, FK restrict).
  4. Правила и переходы. Применить правку полей (Code/Kind не меняются). Переход статуса:
    • целевой == текущий — чистая правка полей;
    • Draft → Active — публикация, Status = Active;
    • Draft/Active → Archived — архивация: Status = Archived, пакет помечается архивным (тот же путь — DELETE). В обоих путях Version++, автор правки — администратор (UpdatedByUserId). INV-2: снапшот-поля покупок не трогаются, событий пересчёта нет.
  5. Сохранение. Сохранить изменённый пакет (при архивации — с отметкой архивации DeletedAt/ DeletedByUserId). Конкурентность: гонка со вторым админом фиксируется по версии — повтор с тем же Idempotency-Key после перечитывания. Потребители буста не затрагиваются (INV-2). Интеграционные и доменные события правка/архив пакета не публикует (pin.promotion.purchase-changed.v1 шлют переходы покупок, не пакетов).
  6. Результат. Вернуть карточку пакета (Version++, новый Status; ArchivedAt при архиве) → 200; ошибки → коды таблицы ниже.

Видимость и постусловие. Пакеты — source of truth этого контекста, лага нет: публикация Draft → Active делает пакет покупаемым на витрине застройщика немедленно после сохранения; архивация → Archived немедленно убирает его из витрины (Active-фильтр), но уже созданные покупки остаются Active и доживают срок (FK restrict) — правка/архив пакета их снапшот не трогает (INV-2) и не публикует никаких событий, поэтому денормализованный буст у потребителей catalog/news не меняется. Проигравший в гонке двух администраторов (несовпавший ExpectedVersion или конфликт версии) получает CONCURRENCY_CONFLICT и повторяет с тем же Idempotency-Key после перечитывания актуальной версии.

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Диапазоны/длины/enum полейОшибка валидации запросаno retry
PROMOTION_PACKAGE_IMMUTABLE_FIELD400Попытка изменить Code/KindПоле пакета неизменяемоno retry
PROMOTION_PACKAGE_MAX_TARGETS_RULE400MaxTargetsPerPurchase не согласован с KindЛимит целей задаётся только для multi-target пакетаno retry
PROMOTION_CURRENCY_UNKNOWN422Price.CurrencyId не найден в справочнике валют или запись архивна (internal resolve, INV-5)Валюта не найдена в справочникеno retry (исправить валюту)
FORBIDDEN403Клиент не pin-admin-system / нет promotion.packages.adminДоступ запрещёнno retry
PROMOTION_PACKAGE_NOT_FOUND404Пакет отсутствует или уже архивирован (soft-deleted)Пакет продвижения не найденno retry
PROMOTION_PACKAGE_INVALID_STATUS_TRANSITION409Переход вне матрицы (напр. Active → Draft)Недопустимый переход статуса пакетаno retry
CONCURRENCY_CONFLICT409ExpectedVersion не совпал или конфликт optimistic-блокировкиДанные изменены параллельно; обновите формуretry с тем же Idempotency-Key после перечитывания
PROMOTION_HANDBOOK_UNAVAILABLE503Справочник недоступен при резолве Price.CurrencyId (fail-closed)Сервис временно недоступенretry (тот же Idempotency-Key)

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

  • Новые optional поля body/результата — additive; удаление/переименование/смена типа, снятие неизменяемости Code/Kind, расширение матрицы переходов — breaking. Неизвестное значение StatusValidationError.
  • Logs: admin-promotion-package-updated (packageId, code, from → to статус, набор изменённых полей без значения Price, adminUserId); metrics: promotion_admin_operations_total{operation="update-promotion-package"} (архивация — operation="archive-promotion-package"), duration; traces — сквозной trace через traceparent. Каждый вызов — в глубоком аудите. Dashboard promotion-overview.

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

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

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