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 |
| Kind | AggregateRoot (жизненный цикл публикации + доменные инварианты контента) |
| Source of truth | этот контекст (для AuthorUserId/AuthorCompanyId/AuthorType — snapshot из identity на момент создания) |
| Таблица | news.posts |
| Tenant isolation | TenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052 п.4); чтение опубликованного — платформенный уровень |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор поста. |
AuthorUserId | Guid | Да | index ix_posts_author; логическая ссылка identity.users(id) без FK | identity (актор) | Автор поста. Дом реплики — identity-replicas.md. |
AuthorType | PostAuthorType | Да | enum (text), varchar(16) | snapshot identity на момент создания | Тип автора; фиксируется при создании (правки роли автора пост не меняют). |
AuthorCompanyId | Guid? | Нет | index ix_posts_company where author_company_id is not null; = TenantId | snapshot identity | Компания застройщика/агентства (INV-NWS-003). null — инвестор/сотрудник. |
CategoryId | Guid | Да | FK fk_posts_category → news.post_categories(id) restrict; index ix_posts_category | authoring | Категория поста (post-category.md). |
Name | string | Да | varchar(256), 1..256 | authoring | «Название» — внутреннее/отображаемое имя; индексируется в search-проекцию. |
Suffix | string | Да | varchar(128), 1..128, kebab-case; unique ux_posts_suffix where deleted_at is null | authoring | «Суффикс» — URL-slug, база для /news/{suffix} (INV-NWS-001). |
Title | string | Да | varchar(512), 1..512 | authoring | «Заголовок поста»; индексируется в search-проекцию. |
Subtitle | string? | Нет | varchar(1024) | authoring | «Подзаголовок». |
Content | PostContentDocument (jsonb) | Да | typed jsonb, версионируется (ContractVersion); колонка content | authoring | Типизированные блоки; дом — post-block.md. |
CoverFileId | Guid? | Нет | без FK (storage/CDN) | authoring | Обложка поста. |
ProjectRefs | PostProjectRefs (jsonb) | Нет | колонка project_refs; GIN ix_posts_project_refs; INV-NWS-003 | authoring | Привязка застройщика к своим проектам. См. VO. |
SearchText | string | Да | varchar/text, <= 200000 | app (пересчёт при set-blocks) | Денормализованный plaintext текстовых блоков — источник поисковой проекции (../search/posts.md, BlocksText); PostgreSQL-tsvector/GIN не используется (ADR-0059). |
CommentsEnabled | bool | Да | default true | authoring | Переключаемые комментарии; гейт INV-NWS-006. |
CommentsCount | int | Да | >= 0, default 0 | app (денорм., reconcile-job) | Число комментариев. |
ViewsCount | long | Да | >= 0, default 0 | app (денорм.; трекинг — user-activity) | Отображаемые просмотры; ranking-вклад отложен. |
PromotionBoost | int | Да | >= 0, default 0 | app (контекст promotion, не автор) | Денормализованный суммарный вес активных покупок продвижения этого поста (цели TargetKind = Post, Σ BoostWeight). Наполняется консюмером ../consumers/promotion-boost.md; пользователь не редактирует. Вход терма Promotion ранжирования (post-ranking-model.md). ADR-0060. |
Status | PostStatus | Да | enum (text), varchar(16), index ix_posts_status | app | См. переходы. |
CurrentVersionNumber | int | Да | >= 0, default 0 | app | Номер последнего post-version.md; 0 — неопубликованный черновик. |
PublishedAt | DateTimeOffset? | Нет | index (композит ix_posts_feed) | app | Момент текущей публикации (сортировка ленты). |
FirstPublishedAt | DateTimeOffset? | Нет | — | app | Момент первой публикации (не меняется при republish). |
CreatedAt / CreatedByUserId / UpdatedAt / UpdatedByUserId / DeletedAt / DeletedByUserId | — | стандарт | — | app | Audit + soft-delete. |
Enum PostStatus (varchar(16))
| Значение | Описание |
|---|---|
Draft | Черновик: не в ленте и не в search-проекции. Начальный статус после создания. |
Published | В ленте (global/subscription) и в поисковой проекции (../search/posts.md). |
Unpublished | Снят с публикации автором: не в ленте/поиске (документ проекции удалён), черновик правок сохраняется, возможен republish. |
Archived | Soft-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)
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
ProjectIds | IReadOnlyList<Guid> | Да (внутри VO; допускается пустой) | уникальные, count <= 20; логические ссылки на проекты chessboard/catalog без FK | project_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 |
|---|---|---|---|
Comment | reference (дочерние) | comments.post_id → posts.id | cascade soft (архив поста мягко скрывает комментарии) |
PostVersion | reference (append-only история) | post_versions.post_id → posts.id | restrict (история публикаций неудаляема) |
PostCategory | reference | category_id | restrict |
SelectionSnapshot | reference (логическая, через блоки) | SnapshotId в блоке | detach Active-snapshot-ов при delete (reason PostDeleted) |
identity.users / identity.companies | replica-источник | AuthorUserId / AuthorCompanyId | — (snapshot) |
Инварианты и переходы состояний
| Инвариант | Условие | Когда проверяется | Ошибка |
|---|---|---|---|
| INV-NWS-001 SuffixUnique | Suffix уникален среди неудалённых (kebab-case) | при создании/публикации (unique index ux_posts_suffix where deleted_at is null) | NEWS_SUFFIX_TAKEN |
| INV-NWS-002 CategoryEligibility | publish разрешён только при AuthorType ∈ Category.AllowedAuthorTypes ∧ permission news.posts.publish | при создании/публикации | NEWS_CATEGORY_NOT_ALLOWED |
| INV-NWS-003 CompanyAttribution | AuthorCompanyId — только AuthorType ∈ {DeveloperManager, AgencyManager}; непустой ProjectRefs — только DeveloperManager | при создании/правке поста | NEWS_ATTRIBUTION_NOT_ALLOWED |
| INV-NWS-004 PublishRequiresContent | publish требует непустой 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 = Published ∧ CommentsEnabled = true | при добавлении комментария | NEWS_COMMENTS_DISABLED |
| INV-NWS-007 RecordLevel | мутации — только AuthorUserId, либо менеджер компании в scope AuthorCompanyId, либо permission news.posts.manage | при любой мутации поста | 403 |
| INV-NWS-008 SearchTextSync | SearchText (денорм. plaintext) пересчитывается при изменении Title/Subtitle/Name/блоков в одном согласованном изменении; поисковая проекция обновляется при публикации/правке Published-поста (../search/posts.md) | при изменении контента/заголовков | нет — механика |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
PostContentChangedEvent | доменное | set-blocks / изменение Title/Subtitle/Name | Id — триггер пересчёта 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, публикуемые одновременно, страхуются uniqueux_posts_suffix(wheredeleted_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.mdINV-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 на не-
Activesnapshot; когда публикация; тогда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 не используется |
| Партиционирование / retention | none (объём умеренный, ADR-0056); Archived чистятся retention-job — отложено |
| Concurrency | оптимистическая блокировка (правка не должна затирать чужую) |
| Permissions / PII | Чтение опубликованного — public (Published); authoring — permission news.posts.* + record-level (INV-NWS-007); PII нет |
| Audit | Actor-stamping (Created/Updated/DeletedByUserId) + история PostVersion (snapshot каждой публикации) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (7): Entity / Aggregate: AuthorReplica + CompanyReplica, Entity / Aggregate: Comment (вложенные, переключаемые), Entity / Aggregate: PostCategory, Entity / Aggregate: PostReaction + проекция PostRankingSignals (Design), Entity / Aggregate: PostVersion, Entity: AuthorSubscription (лента subscription), JSONB document: PostContentDocument (typed-блоки поста)
- API (17): Admin: модерация постов и комментариев (m2m), DELETE /api/v1/comments/{id} — Удалить/скрыть комментарий, DELETE /api/v1/posts/{id} — Удалить пост (soft + detach snapshots), GET /api/v1/authoring/posts — Мои посты и черновики, GET /api/v1/feed/subscriptions — Лента по подпискам, GET /api/v1/posts — Глобальная лента + поиск, GET /api/v1/posts/{id}/comments — Дерево комментариев поста, GET /api/v1/posts/{id}/versions — История версий поста (snapshot), GET /api/v1/posts/{id}/versions/{versionNumber} — Конкретный snapshot версии поста, GET /api/v1/posts/{idOrSuffix} — Чтение опубликованного поста, POST /api/v1/posts — Создать черновик поста, POST /api/v1/posts/{id}/comments — добавить комментарий/ответ (+5)
- Use Cases (7): UC-NWS-001. Создать черновик и опубликовать пост, UC-NWS-002. Встроить подборку в пост (иммутабельный snapshot), UC-NWS-003. Вложенные переключаемые комментарии, UC-NWS-004. Подписки и лента subscription, UC-NWS-005. Поиск постов (Elasticsearch), UC-NWS-006. История версий поста (полный snapshot), UC-NWS-007. Категории по типам авторов (eligibility публикации)
- События (4): События сервиса news: карта топиков и отклонённые события/консюмеры, Event Contract: pin.news.comment-added (CommentAddedKafkaEvent), Event Contract: pin.news.post-published (PostPublishedKafkaEvent), Event Contract: pin.promotion.purchase-changed (PromotionPurchaseChangedKafkaEvent)
- Консюмеры (4): Consumer: identity.companies.v1 → CompanyReplica, Consumer: identity.users.v1 → AuthorReplica (news-identity-users), Consumer: Promotion Boost (pin-catalog-promotion-boost), Consumer: Promotion Boost (pin-news-promotion-boost)
- Фоновые задачи (2): Background Job: CommentCountReconcileJob, Background Job: SnapshotDetachRetryJob
- Поиск (1): PostSearchDocument
- Дашборды (1): Панель Наблюдаемости: news-overview (эксплуатация)
- adr (1): ADR-0060: Продвижение — отдельный bounded context promotion (полиморфный таргет); catalog и news — консюмеры буста
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Promotion Service (Pin.Promotion)
Связанные документы
post-block.md(дом блоков контента),post-version.md(история публикаций),comment.md,post-category.md,identity-replicas.md,post-ranking-model.md,../consumers/promotion-boost.md(наполняетPromotionBoost),../search/posts.md(поисковая проекция).- Дома источников:
../../selections/domain/selection-snapshot.md,../../identity/domain/user.md,../../catalog/domain/catalog-project-card.md,../../chessboard/domain/project.md. - Событие:
../events/post-published.md. - ADR: ADR-0052 (tenancy),
ADR-0053 (m2m),
ADR-0056 (масштаб),
ADR-0059 (поисковые проекции),
ADR-0060 (
promotion— источникPromotionBoost).