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

PUT /api/v1/posts/{id} — обновить метаданные поста

Authoring-точка правки метаданных поста ленты новостей. Поля агрегата — дом post.md, справочник категорий — дом post-category.md. Правит только метаданные (name/suffix/title/subtitle/categoryId/projectRefs); блоки контента — отдельная операция PUT /api/v1/posts/{id}/blocks, публикация — POST /api/v1/posts/{id}/publish (карта — README).

Назначение

Позволить автору (или менеджеру компании-автора) обновить редактируемые метаданные существующего поста в статусе Draft/Unpublished/Published без смены статуса и без записи PostVersion: слепок истории пишется только при (re)publish (INV-NWS-005, post-version.md INV-PV-004). Категория ревалидируется на eligibility и атрибуцию, суффикс — на уникальность, SearchText пересчитывается при изменении текстовых полей (INV-NWS-008).

Metadata

ПолеЗначение
Сервис/контекстnews
API areaauthoring
Feature groupauthoring
Статусdraft
Документdocs/06-services/news/api/authoring/update-post-draft.md

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

ПроверкаЗначение
Actor typeUser (аутентифицированный автор; guest — 401)
Auth policy / PermissionsJWT identity; news.posts.manage-own (self-scope автора) либо news.posts.manage (менеджер компании/эскалация). Каталог — identity/domain/permission.md
Scope (RBAC)self + company: правит AuthorUserId = actor.UserId либо менеджер в scope AuthorCompanyId
Record-levelINV-NWS-007: мутация разрешена только автору, менеджеру компании в scope AuthorCompanyId или носителю news.posts.manage; иначе 403
Tenant isolationTenantId = Post.AuthorCompanyId (nullable, ADR-0052 п.4); посты инвестора/сотрудника — вне tenant, гейт по record-level AuthorUserId
Auditupdate: UpdatedByUserId = actor.UserId, UpdatedAt, инкремент доменного Version; PII нет

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/v1/posts/{id}
Success status200
Idempotency headerне применяется (PUT семантически идемпотентен; повтор перезаписывает те же поля)
Correlationсквозной trace через traceparent

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

Единая секция: HTTP-источник + типизированное поле request-модели UpdatePostCommand.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
Idroute (id)GuidДа!= Guid.Empty; существующий неудалённый Post (шаг 2)record-level (шаг 2)
NamebodystringДа1..256 после Trimиндексируется в поиск
SuffixbodystringДа1..128, kebab-case ^[a-z0-9]+(-[a-z0-9]+)*$; уникален среди неудалённых, исключая себя (шаг 3)URL-slug (INV-NWS-001)
TitlebodystringДа1..512 после Trimиндексируется в поиск
Subtitlebodystring?Нет<= 1024индексируется в поиск
CategoryIdbodyGuidДа!= Guid.Empty; Active-категория + eligibility (шаг 3)
ProjectRefsbodyPostProjectRefsНетProjectIds: уникальны, count <= 20; непустой — только AuthorType = DeveloperManager (INV-NWS-003)логические ссылки (без FK)
ActorUserIdidentity claimsGuidДаиз JWT акторастамп, не из тела

ProjectRefs — именованный контейнер привязок к проектам (дом — post.md).

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

Проекция полей агрегата Post (дом — post.md); ниже — дельта состава ответа.

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаPost.IdИдентификатор поста.
AuthorUserIdGuidДаPost.AuthorUserIdАвтор (не меняется правкой).
AuthorTypePostAuthorType (enum строкой)ДаPost.AuthorTypeТип автора (зафиксирован при создании).
AuthorCompanyIdGuid?НетPost.AuthorCompanyIdКомпания = TenantId; null вне tenant.
CategoryIdGuidДаPost.CategoryIdАктуальная категория.
NamestringДаPost.NameОбновлённое название.
SuffixstringДаPost.SuffixОбновлённый URL-slug.
TitlestringДаPost.TitleОбновлённый заголовок.
Subtitlestring?НетPost.SubtitleОбновлённый подзаголовок.
ProjectRefsPostProjectRefsНетPost.ProjectRefsПривязка к проектам (container-VO).
CoverFileIdGuid?НетPost.CoverFileIdОбложка (правится в blocks-операции).
StatusPostStatus (enum строкой)ДаPost.StatusНе меняется этой операцией.
CommentsEnabledboolДаPost.CommentsEnabledФлаг комментариев.
CurrentVersionNumberintДаPost.CurrentVersionNumberНе инкрементируется правкой (0 для неопубликованных).
PublishedAtDateTimeOffset?НетPost.PublishedAtНе меняется.
UpdatedAtDateTimeOffsetДаPost.UpdatedAtМомент этой правки.
VersionlongДаPost.VersionДоменная версия после инкремента.

Алгоритм

  1. Контекст и доступ. Правит автор поста или менеджер компании-автора (User); гость → 401. Требуется news.posts.manage-own (self) либо news.posts.manage (менеджер компании/эскалация). PUT идемпотентен: повтор перезаписывает те же поля.
  2. Проверка входа и доступа. Форматы и длины Name/Suffix/Title/Subtitle, kebab-паттерн Suffix, CategoryId задан, ProjectRefs.ProjectIds уникальны и не более 20; нарушение → VALIDATION_FAILED. Найти пост по Id; если не найден или Archived (soft-deleted) → NEWS_POST_NOT_FOUND. Редактируемы статусы Draft/Unpublished/Published; правка Published не меняет статус и не пишет PostVersion (INV-NWS-005 / INV-PV-004 — версия только при (re)publish). Record-level (INV-NWS-007): актор — автор, либо менеджер компании в scope AuthorCompanyId, либо носитель news.posts.manage; иначе → FORBIDDEN (403).
  3. Ревалидация категории и суффикса. Найти категорию по CategoryId; если не найдена → NEWS_CATEGORY_NOT_FOUND. eligibility (INV-NWS-002 / INV-CAT-002): категория Active и AuthorType входит в AllowedAuthorTypes; иначе → NEWS_CATEGORY_NOT_ALLOWED. Атрибуция (INV-NWS-003): непустой ProjectRefs допустим только при AuthorType = DeveloperManager; иначе → NEWS_ATTRIBUTION_NOT_ALLOWED. Суффикс должен быть свободен среди неудалённых постов (кроме самого поста), иначе → NEWS_SUFFIX_TAKEN (INV-NWS-001).
  4. Правка метаданных. Обновить Name/Suffix/Title/Subtitle/CategoryId/ProjectRefs, actor-stamp, инкремент доменной версии. Status/PublishedAt/FirstPublishedAt/CurrentVersionNumber не трогаются; PostVersion не добавляется. Если изменились Name/Title/Subtitle — пересчитать денормализованный SearchText в том же изменении (INV-NWS-008); для Published-поста — обновить поисковую проекцию news-posts (search/posts.md). Конкурентная правка → NEWS_CONCURRENCY_CONFLICT (409, retry с перечиткой). Интеграционных событий нет — метаданные без (re)publish не покидают news.
  5. Результат. PostResult из обновлённого поста → 200.

Диаграмма

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

ТипДетали
Integration eventsnone (без интеграционных событий; post-published — только на (re)publish)
External callsnone (категория/суффикс читаются из своей БД; selections не вызывается — блоки не трогаются)
Cache / projections / searchпересчёт денорм. SearchText; для Published-поста — upsert Elasticsearch-проекции news-posts (INV-NWS-008, search/posts.md)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400формат/длины полей, kebab-case суффикса, ProjectIds > 20/дубли«Проверьте корректность полей»no retry
NEWS_POST_NOT_FOUND404пост не найден или Archived (soft-deleted)«Пост не найден»no retry
NEWS_CATEGORY_NOT_FOUND404CategoryId не существует«Категория не найдена»no retry
NEWS_CATEGORY_NOT_ALLOWED409category.Status != ActiveAuthorType ∉ AllowedAuthorTypes (INV-NWS-002)«Категория недоступна для этого типа автора»no retry
NEWS_ATTRIBUTION_NOT_ALLOWED409непустой ProjectRefs при AuthorType != DeveloperManager (INV-NWS-003)«Привязка к проектам недоступна»no retry
NEWS_SUFFIX_TAKEN409суффикс занят другим неудалённым постом (INV-NWS-001)«Суффикс уже используется»no retry
NEWS_CONCURRENCY_CONFLICT409конфликт xmin/Version при Update«Пост изменён параллельно, повторите»retry (перечитка)
FORBIDDEN403нарушение record-level (INV-NWS-007)«Недостаточно прав на правку поста»no retry

Предусловия и постусловия

Предусловия (истинны до сценария):

  • актор — автор поста, менеджер компании-автора в scope AuthorCompanyId, либо носитель news.posts.manage (INV-NWS-007);
  • пост существует, неудалён и в редактируемом статусе Draft/Unpublished/Published;
  • целевая категория существует, Active и допускает AuthorType (eligibility), новый Suffix свободен среди неудалённых (кроме самого поста).

Постусловия (гарантированы после успешной правки):

  • обновлены Name/Suffix/Title/Subtitle/CategoryId/ProjectRefs; Status/PublishedAt/FirstPublishedAt/CurrentVersionNumber не изменены; новый PostVersion не добавлен (INV-NWS-005 / INV-PV-004 — версия только при (re)publish);
  • при изменении Name/Title/Subtitle пересчитан денормализованный SearchText (INV-NWS-008); доменная версия поста +1; actor-stamp UpdatedByUserId/UpdatedAt;
  • интеграционных Kafka-событий нет — правка метаданных без (re)publish не покидает news.

Интеграционная последовательность

Всё выполняется синхронно в запросе и фиксируется атомарно (правка полей + пересчёт SearchText). Кросс-сервисных синхронных вызовов нет: категория и уникальность суффикса читаются из своей схемы, selections не вызывается (блоки не трогаются). Что предоставляет наружу: для Published-поста — обновление поисковой проекции news-posts (upsert по изменившемуся SearchText/метаданным, окно свежести — секунды; в этот промежуток лента и чтение поста уже показывают новые метаданные из source of truth, поиск догоняет). Что ожидает: реплика категории/справочника локальна; внешних гарантий не требует. Конкурентность: оптимистическая блокировка версии поста — при устаревшей версии → NEWS_CONCURRENCY_CONFLICT, проигравший перечитывает и повторяет; компенсаций нет — одна строка агрегата.

Acceptance Criteria

  • Правка метаданных черновика. Дано: автор, пост Draft, валидные поля, категория Active и eligible, Suffix свободен. Когда: PUT /api/v1/posts/{id}. Тогда: 200, поля обновлены, Status/CurrentVersionNumber неизменны, PostVersion не добавлен, событий нет.
  • Правка опубликованного без смены статуса. Дано: пост Published, изменён Title. Когда: PUT. Тогда: 200, Status = Published, PostVersion не пишется, поисковая проекция news-posts обновлена по новому SearchText.
  • Смена категории на недопустимую. Дано: AuthorType ∉ Category.AllowedAuthorTypes или категория не Active (INV-NWS-002). Когда: PUT. Тогда: 409 NEWS_CATEGORY_NOT_ALLOWED.
  • Недопустимая атрибуция проектов. Дано: непустой ProjectRefs при AuthorType != DeveloperManager (INV-NWS-003). Когда: PUT. Тогда: 409 NEWS_ATTRIBUTION_NOT_ALLOWED.
  • Занятый суффикс. Дано: Suffix используется другим неудалённым постом (INV-NWS-001). Когда: PUT. Тогда: 409 NEWS_SUFFIX_TAKEN.
  • Категория не найдена. Дано: CategoryId не существует. Когда: PUT. Тогда: 404 NEWS_CATEGORY_NOT_FOUND.
  • Пост в архиве/не найден. Дано: пост Archived или отсутствует. Когда: PUT. Тогда: 404 NEWS_POST_NOT_FOUND.
  • Не владелец. Дано: актор не автор, не менеджер в scope и без news.posts.manage (INV-NWS-007). Когда: PUT. Тогда: 403 FORBIDDEN.
  • Конкурентная правка. Дано: пост изменён параллельно (устаревшая версия). Когда: PUT. Тогда: 409 NEWS_CONCURRENCY_CONFLICT, клиент перечитывает и повторяет.
  • Невалидный вход. Дано: нарушены длины/паттерн Suffix, ProjectIds > 20/дубли. Когда: PUT. Тогда: 400 VALIDATION_FAILED.

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

  • Subtitle/ProjectRefs — optional; новые optional-поля запроса/ответа — additive; удаление/ переименование/смена типа — breaking (migration + rollout). PostResult — стабильный контракт; enum-поля (PostStatus/PostAuthorType) сериализуются строкой, unknown значения не вводятся.
  • Logs: post-metadata received/updated (PostId, ActorUserId, изменённые поля); metrics: news_post_update_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard news-overview; runbook — «пост показывает не тот состав» (сверка после правок).

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

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

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

  • Сущности: post.md (поля, INV-NWS-001/002/003/005/007/008, PostProjectRefs, PostStatus/PostAuthorType), post-category.md (eligibility INV-CAT-002, AllowedAuthorTypes), post-version.md (INV-PV-004 — правка версию не пишет), post-block.md (SearchText, блоки — иная операция).
  • Соседние authoring-методы: POST /api/v1/posts (создание черновика), PUT /api/v1/posts/{id}/blocks (блоки), POST /api/v1/posts/{id}/publish (публикация + PostVersion) — карта в README.
  • Permission-каталог: identity/domain/permission.md (news.posts.manage-own, news.posts.manage).
  • ADR: ADR-0052, ADR-0056.