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

Entity / Aggregate: Post

Дом полей агрегата поста ленты новостей (контекст news). Все проекции — модели ответов public/authoring API, payload интеграционных событий, snapshot post-version.md, search-проекция (../search/posts.md) и ранжирование (post-ranking-model.md) — ссылаются сюда и описывают только дельту, а не переописывают поля.

Назначение

Post — корень агрегата пользовательского поста ленты новостей: авторский контент (типизированные блоки), его публикация в общую/подписную ленту, полнотекстовый поиск и комментирование. Агрегат владеет собственным контентом (jsonb-блоки), денормализованными счётчиками (комментарии, просмотры) и денормализованным весом продвижения (PromotionBoost — наполняется контекстом promotion, ADR-0060), а также историей публикаций через дочерний post-version.md.

Структура типизированных блоков контента описана в post-block.md; замороженные копии подборок в блоках хранятся в ../../selections/domain/selection-snapshot.md (news хранит только SnapshotId); профиль автора реплицируется — identity-replicas.md. Этот документ владеет полями самого поста.

Классификация

ПолеЗначение
Контекстnews
KindAggregateRoot (жизненный цикл публикации + доменные инварианты контента)
Source of truthэтот контекст (для AuthorUserId/AuthorCompanyId/AuthorType — snapshot из identity на момент создания)
Таблицаnews.posts
Tenant isolationTenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052 п.4); чтение опубликованного — платформенный уровень

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор поста.
AuthorUserIdGuidДаindex ix_posts_author; логическая ссылка identity.users(id) без FKidentity (актор)Автор поста. Дом реплики — identity-replicas.md.
AuthorTypePostAuthorTypeДаenum (text), varchar(16)snapshot identity на момент созданияТип автора; фиксируется при создании (правки роли автора пост не меняют).
AuthorCompanyIdGuid?Нетindex ix_posts_company where author_company_id is not null; = TenantIdsnapshot identityКомпания застройщика/агентства (INV-NWS-003). null — инвестор/сотрудник.
CategoryIdGuidДаFK fk_posts_category → news.post_categories(id) restrict; index ix_posts_categoryauthoringКатегория поста (post-category.md).
NamestringДаvarchar(256), 1..256authoring«Название» — внутреннее/отображаемое имя; индексируется в search-проекцию.
SuffixstringДаvarchar(128), 1..128, kebab-case; unique ux_posts_suffix where deleted_at is nullauthoring«Суффикс» — URL-slug, база для /news/{suffix} (INV-NWS-001).
TitlestringДаvarchar(512), 1..512authoring«Заголовок поста»; индексируется в search-проекцию.
Subtitlestring?Нетvarchar(1024)authoring«Подзаголовок».
ContentPostContentDocument (jsonb)Даtyped jsonb, версионируется (ContractVersion); колонка contentauthoringТипизированные блоки; дом — post-block.md.
CoverFileIdGuid?Нетбез FK (storage/CDN)authoringОбложка поста.
ProjectRefsPostProjectRefs (jsonb)Нетколонка project_refs; GIN ix_posts_project_refs; INV-NWS-003authoringПривязка застройщика к своим проектам. См. VO.
SearchTextstringДаvarchar/text, <= 200000app (пересчёт при set-blocks)Денормализованный plaintext текстовых блоков — источник поисковой проекции (../search/posts.md, BlocksText); PostgreSQL-tsvector/GIN не используется (ADR-0059).
CommentsEnabledboolДаdefault trueauthoringПереключаемые комментарии; гейт INV-NWS-006.
CommentsCountintДа>= 0, default 0app (денорм., reconcile-job)Число комментариев.
ViewsCountlongДа>= 0, default 0app (денорм.; трекинг — user-activity)Отображаемые просмотры; ranking-вклад отложен.
PromotionBoostintДа>= 0, default 0app (контекст promotion, не автор)Денормализованный суммарный вес активных покупок продвижения этого поста (цели TargetKind = Post, Σ BoostWeight). Наполняется консюмером ../consumers/promotion-boost.md; пользователь не редактирует. Вход терма Promotion ранжирования (post-ranking-model.md). ADR-0060.
StatusPostStatusДаenum (text), varchar(16), index ix_posts_statusappСм. переходы.
CurrentVersionNumberintДа>= 0, default 0appНомер последнего post-version.md; 0 — неопубликованный черновик.
PublishedAtDateTimeOffset?Нетindex (композит ix_posts_feed)appМомент текущей публикации (сортировка ленты).
FirstPublishedAtDateTimeOffset?НетappМомент первой публикации (не меняется при republish).
CreatedAt / CreatedByUserId / UpdatedAt / UpdatedByUserId / DeletedAt / DeletedByUserIdстандартappAudit + soft-delete.

Enum PostStatus (varchar(16))

ЗначениеОписание
DraftЧерновик: не в ленте и не в search-проекции. Начальный статус после создания.
PublishedВ ленте (global/subscription) и в поисковой проекции (../search/posts.md).
UnpublishedСнят с публикации автором: не в ленте/поиске (документ проекции удалён), черновик правок сохраняется, возможен republish.
ArchivedSoft-deleted (DeletedAt задан): недоступен; embed-snapshots блоков отвязаны (Detached).

Enum PostAuthorType (varchar(16))

ЗначениеОписание
InvestorИнвестор (вне tenant; AuthorCompanyId = null, ProjectRefs пуст).
DeveloperManagerМенеджер застройщика (tenant = компания; допустимы ProjectRefs).
AgentАгент (вне tenant).
AgencyManagerМенеджер агентства (tenant = компания; ProjectRefs не допускается).
PinEmployeeСотрудник PIN (вне tenant).

Snapshot UserType из identity на момент создания; расхождение с текущей ролью автора пост не меняет.

Value objects

PostProjectRefs (jsonb-колонка project_refs)

ПолеТипОбязательностьОграниченияКолонка
ProjectIdsIReadOnlyList<Guid>Да (внутри VO; допускается пустой)уникальные, count <= 20; логические ссылки на проекты chessboard/catalog без FKproject_refs (jsonb-массив)

Непустой список допустим только при AuthorType = DeveloperManager (INV-NWS-003). Фильтр projectId по GIN jsonb_path_ops.

Модели JSONB (versioned)

Content = PostContentDocument (ContractVersion) — типизированный документ блоков поста. Дом модели и всех типов блоков — post-block.md; здесь описывается только факт хранения в колонке content и участие в пересчёте SearchText (INV-NWS-008). SelectionEmbed-блоки хранят SnapshotId../../selections/domain/selection-snapshot.md.

Связи

СвязьТипКлючDelete behavior
Commentreference (дочерние)comments.post_id → posts.idcascade soft (архив поста мягко скрывает комментарии)
PostVersionreference (append-only история)post_versions.post_id → posts.idrestrict (история публикаций неудаляема)
PostCategoryreferencecategory_idrestrict
SelectionSnapshotreference (логическая, через блоки)SnapshotId в блокеdetach Active-snapshot-ов при delete (reason PostDeleted)
identity.users / identity.companiesreplica-источникAuthorUserId / AuthorCompanyId— (snapshot)

Инварианты и переходы состояний

ИнвариантУсловиеКогда проверяетсяОшибка
INV-NWS-001 SuffixUniqueSuffix уникален среди неудалённых (kebab-case)при создании/публикации (unique index ux_posts_suffix where deleted_at is null)NEWS_SUFFIX_TAKEN
INV-NWS-002 CategoryEligibilitypublish разрешён только при AuthorType ∈ Category.AllowedAuthorTypes ∧ permission news.posts.publishпри создании/публикацииNEWS_CATEGORY_NOT_ALLOWED
INV-NWS-003 CompanyAttributionAuthorCompanyId — только AuthorType ∈ {DeveloperManager, AgencyManager}; непустой ProjectRefs — только DeveloperManagerпри создании/правке постаNEWS_ATTRIBUTION_NOT_ALLOWED
INV-NWS-004 PublishRequiresContentpublish требует непустой Title>= 1 блок ∧ все SelectionEmbed-блоки ссылаются на Active-snapshotпри публикацииNEWS_POST_EMPTY / NEWS_SNAPSHOT_INVALID
INV-NWS-005 SnapshotOnPublishкаждая публикация (Draft→Published и повторная Published→Published после правок) пишет новый PostVersion (VersionNumber = CurrentVersionNumber + 1) в одном согласованном изменениипри публикациинет — механика
INV-NWS-006 CommentsGateкомментарий добавляется только при Status = PublishedCommentsEnabled = trueпри добавлении комментарияNEWS_COMMENTS_DISABLED
INV-NWS-007 RecordLevelмутации — только AuthorUserId, либо менеджер компании в scope AuthorCompanyId, либо permission news.posts.manageпри любой мутации поста403
INV-NWS-008 SearchTextSyncSearchText (денорм. plaintext) пересчитывается при изменении Title/Subtitle/Name/блоков в одном согласованном изменении; поисковая проекция обновляется при публикации/правке Published-поста (../search/posts.md)при изменении контента/заголовковнет — механика

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
PostContentChangedEventдоменноеset-blocks / изменение Title/Subtitle/NameId — триггер пересчёта SearchText (INV-NWS-008) в том же изменении; для Published-поста — upsert search-проекции (../search/posts.md)
PostPublishedKafkaEventинтеграционное (топик pin.news.post-published.v1, key = Id, ContractVersion = 1)Publish/Republish (→ Published)PostId, AuthorUserId, AuthorType, AuthorCompanyId, CategoryId, Suffix, Title, Subtitle, CoverFileId, ProjectIds, VersionNumber, PublishedAt, FirstPublishedAt. Дом контракта — ../events/post-published.md

Интеграционное событие фиксируется в одном согласованном изменении с публикацией и записью PostVersion. Потребители: лента подписок, notifications.

Конкурентность, согласованность и окна видимости

  • Публикация — предусловия/постусловия. Публикация допустима из Draft/Unpublished при выполнении INV-NWS-002 (eligibility категории), INV-NWS-004 (непустой контент, все embed-snapshot-ы Active), INV-NWS-001 (уникальный Suffix). Постусловие: Status = Published, задан PublishedAt (при первой — и FirstPublishedAt), CurrentVersionNumber увеличен на 1, записан новый PostVersion (INV-NWS-005), пересчитан SearchText (INV-NWS-008), опубликовано PostPublishedKafkaEvent — всё в одном согласованном изменении.
  • Republish. PublishedAt обновляется на новый момент (сортировка ленты «поднимает» пост), FirstPublishedAt не меняется; каждая публикация после правок пишет новый PostVersion.
  • Гонка двух публикаций одного поста. Оптимистическая блокировка агрегата + unique ux_post_versions_number (post_id, version_number): один переход фиксируется, конкурент получает конфликт версии (NEWS_VERSION_CONFLICT, дом post-version.md) и перечитывает актуальное состояние. Двойного PostVersion с одним номером не возникает.
  • Гонка по Suffix. Два поста с одинаковым Suffix, публикуемые одновременно, страхуются unique ux_posts_suffix (where deleted_at is null): второй — NEWS_SUFFIX_TAKEN.
  • Окно видимости после публикации. В ленте global и по прямой ссылке /news/{suffix} пост виден сразу после коммита (частичный индекс ix_posts_feed). Лента subscription — pull-запрос подписчика, тоже сразу (события не требует). Поисковая проекция news-posts (fts, ADR-0059) догоняет через секунды (upsert по PostContentChangedEvent); краткий промежуток пост может не находиться поиском при уже видимой ленте. notifications рассылает подписчикам асинхронно (SLA < 1 мин).
  • Удаление и detach. Delete (soft) переводит в Archived и инициирует отвязку Active-snapshot-ов embed-блоков (reason = PostDeleted) синхронным m2m-вызовом selections; связь PostId ↔ SnapshotId держится в транзакции news. Историю (PostVersion) удаление не трогает (restrict).
  • Денормализованные счётчики. CommentsCount/ViewsCount/PromotionBoost меняются вне основного write-пути автора (доменные обработчики, трекинг, консюмер promotion); их обновление не бампит доменную версию поста и не пишет PostVersion. Дрейф CommentsCount устраняет ночной reconcile-job (INV-NWS, comment.md INV-CMT-005).

Acceptance Criteria

  • Черновик не в ленте. Дано созданный пост в Draft; когда запрашиваются ленты global/subscription и поиск; тогда пост не виден нигде и отсутствует в проекции news-posts.
  • Первая публикация. Дано валидный пост (eligibility ок, >= 1 блок, embed на Active-snapshot); когда автор публикует; тогда Status = Published, заданы PublishedAt = FirstPublishedAt, CurrentVersionNumber = 1, создан PostVersion #1, ушло PostPublishedKafkaEvent.
  • Публикация в чужой категории. Дано AuthorType вне Category.AllowedAuthorTypes; когда автор публикует; тогда отказ NEWS_CATEGORY_NOT_ALLOWED (INV-NWS-002), пост остаётся Draft.
  • Пустой контент. Дано пост без блоков или с embed на не-Active snapshot; когда публикация; тогда NEWS_POST_EMPTY / NEWS_SNAPSHOT_INVALID (INV-NWS-004).
  • Дубликат суффикса. Дано другой неудалённый пост с тем же Suffix; когда публикация; тогда NEWS_SUFFIX_TAKEN (INV-NWS-001).
  • Republish поднимает пост. Дано Published пост с правками; когда автор публикует повторно; тогда PublishedAt обновлён, FirstPublishedAt сохранён, создан новый PostVersion.
  • Атрибуция компании. Дано AuthorType = Investor; когда задаётся AuthorCompanyId или непустой ProjectRefs; тогда NEWS_ATTRIBUTION_NOT_ALLOWED (INV-NWS-003).
  • Комментарии-гейт. Дано Status = Published, CommentsEnabled = false; когда пользователь добавляет комментарий; тогда NEWS_COMMENTS_DISABLED (INV-NWS-006).
  • Record-level. Дано актор — не автор и не менеджер компании-автора без news.posts.manage; когда он пытается изменить пост; тогда 403 (INV-NWS-007).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK pk_posts (id); unique ux_posts_suffix (suffix) where deleted_at is null; ленточный композит ix_posts_feed (status, published_at desc) where status='Published'; ix_posts_author (author_user_id, status, published_at desc); ix_posts_company (author_company_id, status, published_at desc) where author_company_id is not null; ix_posts_category (category_id, status, published_at desc); GIN ix_posts_project_refs (project_refs jsonb_path_ops); ix_posts_status (status)
Search projectionПоисковый документ PostSearchDocument (alias news-posts) — только Published; наполняется доменными событиями Post + периодическим rebuild-job (ADR-0059; ../search/posts.md). PostgreSQL-tsvector/GIN не используется
Партиционирование / retentionnone (объём умеренный, ADR-0056); Archived чистятся retention-job — отложено
Concurrencyоптимистическая блокировка (правка не должна затирать чужую)
Permissions / PIIЧтение опубликованного — public (Published); authoring — permission news.posts.* + record-level (INV-NWS-007); PII нет
AuditActor-stamping (Created/Updated/DeletedByUserId) + история PostVersion (snapshot каждой публикации)

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

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

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