News Service (Pin.News)
Назначение
news владеет пользовательским контентом платформы PIN: посты/новости/блоги в общую ленту и
публикации, привязанные к автору, компании или проекту. Пост — блочный контент (типизированные
jsonb-блоки: текст, галерея изображений, embed-подборка из selections) с категориями по типам
авторов, вложенными переключаемыми комментариями и полным snapshot-версионированием истории
публикаций (полный слепок, не diff). Предоставляет: CRUD постов и черновиков, publish/unpublish,
редактирование блоков, ленты global и subscriptions, полнотекстовый поиск постов
(Elasticsearch-проекция, ADR-0059), комментарии и подписки.
Metadata
| Поле | Значение |
|---|---|
| Статус | draft |
| Owning team | PIN Engagement |
| Domain | Вовлечение / Контент (engagement) |
| Data classification | Public/Internal (опубликованные посты и комментарии публичны; черновики — internal владельца; PII нет — авторские ФИО берутся из реплики identity, не хранятся как первичные) |
| Runtime criticality | medium (лента контента; недоступность не блокирует каталог/сделки, но заметна пользователям) |
Ownership
| Artifact | Owner/Rule |
|---|---|
| PostgreSQL schema | news |
| API areas | public (ленты global/subscriptions, чтение поста, комментарии, подписки, справочник категорий), authoring (черновики/публикация/блоки/версии автора — self + company scope), admin (категории, модерация — m2m, ADR-0053) |
| Owned entities | Post, PostBlock (typed jsonb на Post), Comment, PostVersion, PostCategory, AuthorSubscription; проекции-реплики AuthorReplica, CompanyReplica (read-модели из identity); отложенные PostRankingSignals/PostReaction (спроектированы, реализация отложена) |
| Produced events | pin.news.post-published.v1, pin.news.comment-added.v1 |
| Consumed events | pin.identity.users.v1, pin.identity.companies.v1, pin.promotion.purchase-changed.v1 (буст постов, ADR-0060) |
| Синхронные интеграции | selections internal API (create/detach/get SelectionSnapshot для embed-блока, m2m); catalog (display-данные объектов недвижимости при рендере — вызывает фронт/рендер, не бэкенд) |
| Search indexes | Elasticsearch-проекция news-posts (PostSearchDocument, Talent.Common.Domain.Search, ADR-0059; search/posts.md) — только Published-посты; PostgreSQL-FTS (tsvector/GIN) не используется |
| Analytics facts | none (ClickHouse — только dev-stats, ADR-0056/0057; просмотры постов трекает фронт → user-activity ActionEvent, ADR-0054) |
Ответственность И Границы
- Делает: черновики и жизненный цикл поста (
Draft/Published/Unpublished/Archived); редактирование блоков (text/gallery/selection-embed) как typed-jsonb коллекции; иммутабельную копию подборки в пост через синхронный вызовselections(хранитSnapshotIdв блоке; замена блока = новый snapshot + detach старого); категории по типам авторов (PostCategory.AllowedAuthorTypes+ permissionidentity); полное snapshot-версионирование при каждой публикации; вложенные переключаемые комментарии; подписки и лентуsubscriptions; глобальную ленту + поиск/фильтры/сортировки; денормализованные авторские/компанийные подписи (репликиidentity— лента без синхронных джойнов). - НЕ делает: source of truth пользователей/компаний (
identity); подборки и их иммутабельные копии (selections— владеетSelection/SelectionSnapshot, INV-SEL-001;newsлишь ссылается поSnapshotId); display-данные объектов недвижимости embed-блока (батч рендера вcatalog, цены не заморожены — показываются актуальные); трекинг просмотров/лайков как событий (фронт →user-activity, ADR-0054); доставку уведомлений подписчикам (notificationsпо событиюpost-published); медиа-хранилище (storage/CDN —FileId, пресайн-ссылки, без FK); ранжирование/рекомендации (спроектировано, реализация отложена — решение №10). - Source of truth:
Post,Comment,PostVersion,PostCategory,AuthorSubscription. - Реплики/проекции:
AuthorReplica/CompanyReplica— репликиidentity; embed-блок — ссылка наSelectionSnapshot(source of truth —selections).
Зависимости
| Зависимость | Тип | Зачем Нужна | Failure mode |
|---|---|---|---|
PostgreSQL (schema news) | db | Все таблицы (source of truth) + outbox | Полный отказ сервиса |
| Kafka | queue | Produced pin.news.* (outbox), consumed pin.identity.* | CRUD/ленты работают; свежесть авторских подписей и рассылка подписчикам деградируют (outbox/консюмеры догонят) |
| Elasticsearch | service (search projection, Talent.Common.Domain.Search) | Полнотекстовый поиск постов (news-posts, ADR-0059) | Поиск по searchTerm деградирует/недоступен; лента без searchTerm, чтение поста и авторство работают (проекция rebuildable из PostgreSQL) |
selections | service (internal API m2m, синхронно) | Снятие/detach/чтение SelectionSnapshot для embed-блока | Добавление/замена блока-подборки блокируется (NEWS_SELECTIONS_UNAVAILABLE); прочее авторство работает; рендер уже опубликованных блоков читает snapshot по SnapshotId |
identity | service (JWT/permissions + события) | Аутентификация авторов/комментаторов; permission news.*; snapshot автора/компании | Публичное чтение лент/постов живёт; авторство/комментарии — нет; подписи из реплик (staleness при лаге) |
catalog | service (используется фронтом/рендером, не бэкендом) | Актуальные display-данные объектов недвижимости embed-блока | Блок-подборка показывает PropertyObjectId без карточек |
promotion | service (Kafka consumer, ADR-0060) | Буст продвижения постов (pin.promotion.purchase-changed.v1 → Post.PromotionBoost) | PromotionBoost не обновляется (замирает на последнем снапшоте); лента/ранжирование работают |
| Администратор платформы (admin API) | consumer (m2m, ADR-0053) | CRUD категорий, модерация постов/комментариев | Admin-правки недоступны |
Redis/ClickHouse не используются (ADR-0056).
Проекты
src/services/news/
Pin.News.Api/ # public + authoring + admin API areas
Pin.News.Application/ # обработка операций; search-запросы (Elasticsearch); snapshot-версионирование; оркестрация selections-клиента
Pin.News.Domain/ # Post, PostBlock (jsonb), Comment, PostVersion, PostCategory, AuthorSubscription, реплики, enums, инварианты
Pin.News.Infrastructure/ # хранилище PostgreSQL, Elasticsearch search-проекция, outbox, selections-клиент (m2m)
Pin.News.Background/ # consumers: identity, promotion-boost (Post.PromotionBoost); jobs: comment-count-reconcile, snapshot-detach-retry, post-search-rebuild (ranking-recompute — отложен)
Pin.News.Migrator/ # миграции схемы news + SEED PostCategory (категории по типам авторов)
Pin.News.Contracts/ # pin.news.* event contracts
Основные Сущности
| Entity | Назначение | Source Of Truth | Документ (целевой) |
|---|---|---|---|
Post | Агрегат поста: метаданные, категория, статус жизненного цикла, привязки автор/компания/проект, счётчики | news | domain/post.md |
PostBlock | Typed-jsonb блок (text/gallery/selection-embed) как коллекция на Post; embed хранит SnapshotId | news | domain/post.md |
Comment | Вложенное переключаемое дерево комментариев (soft delete, модерация) | news | domain/comment.md |
PostVersion | Immutable snapshot поста при каждой публикации (полное версионирование) | news | domain/post-version.md |
PostCategory | SEED-справочник категорий по типам авторов (AllowedAuthorTypes) | news | domain/post-category.md |
AuthorSubscription | Подписка пользователя на автора (лента subscriptions) | news | domain/author-subscription.md |
AuthorReplica / CompanyReplica | Read-проекции подписи автора/компании из identity (лента без джойнов) | identity (реплика) | domain/identity-replicas.md |
PostRankingSignals / PostReaction | Сигналы ранжирования и реакции — спроектированы, реализация отложена | news | domain/post-ranking-model.md |
ER-обзор
Жизненный цикл поста (первичная и повторная публикация пишут PostVersion):
API (карта областей; детали — api/<area>/...)
- public:
GET /api/v1/posts(глобальная лента, keyset-пагинация, поискsearchTermчерез Elasticsearch, фильтрыcategoryId/authorType/projectId/authorCompanyId, сортировкиrecommended[deferred→date]/date/popularity);GET /api/v1/feed/subscriptions(лента по подпискам, JWT);GET /api/v1/posts/{idOrSuffix}(чтение поста; view-трекинг — фронт→user-activity);GET /api/v1/posts/{id}/comments(дерево комментариев);POST /api/v1/posts/{id}/comments(добавить/ответить →comment-added);DELETE /api/v1/comments/{id}(soft, автор/модерация);POST|DELETE|GET /api/v1/subscriptions[/{id}];GET /api/v1/categories(справочник + eligibility актора). - authoring (self + company scope;
news.posts.create-*по scope автора +news.posts.manage-own):POST /api/v1/posts(черновик);PUT /api/v1/posts/{id}(метаданные: name/suffix/title/subtitle/ category/projectRefs);PUT /api/v1/posts/{id}/blocks(блоки целиком; embed →selectionssnapshot create/detach);POST /api/v1/posts/{id}/publish(→PostVersion+post-published);POST /api/v1/posts/{id}/unpublish;DELETE /api/v1/posts/{id}(soft + detach snapshots);POST /api/v1/posts/{id}/comments-settings(переключитьCommentsEnabled);GET /api/v1/authoring/posts(мои посты/черновики);GET /api/v1/posts/{id}/versions+GET /api/v1/posts/{id}/versions/{versionNumber}. - admin (m2m, ADR-0053;
news.categories.manage,news.posts.moderate): CRUDPostCategory; модерация (скрыть пост/комментарий).
Events И Consumers
| Type | Name | Topic | Назначение |
|---|---|---|---|
| Produced | PostPublishedKafkaEvent | pin.news.post-published.v1 | Уведомления подписчиков (notifications); таймлайн действий автора (user-activity, опц.) |
| Produced | CommentAddedKafkaEvent | pin.news.comment-added.v1 | Уведомление автора поста и автора родительского комментария (notifications) |
| Consumed | UserUpserted / UserBlocked | pin.identity.users.v1 | Upsert AuthorReplica (подпись автора без джойна); blocked → скрытие |
| Consumed | CompanyUpserted / CompanySuspended | pin.identity.companies.v1 | Upsert CompanyReplica; suspended → скрытие постов компании |
| Consumed | PromotionPurchaseChanged | pin.promotion.purchase-changed.v1 | Цели TargetKind = Post → пересчёт Post.PromotionBoost (Σ активных BoostWeight); consumers/promotion-boost.md, ADR-0060 |
Публикуются атомарно с мутацией агрегата; ключ = PostId/CommentId; ContractVersion = 1.
Embed-подборки не через Kafka — синхронный API selections (решение №1).
Кросс-сервисные контракты и согласованность
Что news предоставляет и ожидает на границах, и какие окна eventual-consistency видит пользователь.
| Контракт | Направление | Гарантии / ключ / версия | Поведение при отсутствии/устаревании |
|---|---|---|---|
pin.news.post-published.v1 | предоставляет (async, at-least-once) | ключ PostId, ContractVersion = 1; публикуется атомарно с публикацией; порядок публикаций одного поста сохранён | потребитель (notifications/user-activity) недоступен — событие догоняет из outbox; дубликаты — на потребителе идемпотентно |
pin.news.comment-added.v1 | предоставляет (async, at-least-once) | ключ CommentId, ContractVersion = 1; атомарно с добавлением | аналогично; уведомление автора/родителя догоняет |
pin.identity.users.v1 | ожидает (consumed) | upsert AuthorReplica (подпись автора); blocked → скрытие | реплики нет/лаг → подпись деградирует (fallback), авторство/чтение живут; окно лага < 1 мин (SLO) |
pin.identity.companies.v1 | ожидает (consumed) | upsert CompanyReplica; suspended → скрытие постов компании | лаг → устаревшее название компании до догона |
pin.promotion.purchase-changed.v1 | ожидает (consumed, ADR-0060) | цели TargetKind = Post → пересчёт Post.PromotionBoost | недоступно → PromotionBoost замирает на последнем снапшоте; лента/ранжирование работают |
selections internal API | ожидает (sync, m2m) | create/detach/get SelectionSnapshot; detach идемпотентен (INV-SNP-005), snapshot иммутабелен (INV-SNP-006) | недоступен → добавление/замена embed блокируется (NEWS_SELECTIONS_UNAVAILABLE); отвязка после коммита — надёжный повтор job-ом snapshot-detach-retry |
Elasticsearch-проекция news-posts | предоставляет (внутр. проекция) | только Published-посты; rebuildable из PostgreSQL (ADR-0059) | недоступна → поиск по searchTerm деградирует; лента без поиска, чтение поста и авторство живут |
Окна eventual-consistency (что видит пользователь в промежуточном состоянии):
- Публикация: автор сразу видит пост
Publishedв глобальной ленте (source of truth —news); подписчики получают уведомление и полнотекстовый поиск находит пост — с задержкой (лаг консюмера/проекции < 1 мин). - Правка метаданных/блоков
Published-поста: чтение поста показывает новый состав сразу после коммита; поисковая проекция догоняет в окне секунд. - Снятие/удаление: пост выпадает из лент и чтения немедленно; из поиска — в окне проекции; отвязка embed-snapshot-ов (только при удалении) может выполниться позже job-ом, но исторические
PostVersionвсё это время корректно ссылаются на иммутабельные snapshot-ы. - Подписи автора/компании: eventual — устаревают на время лага консюмеров
identity; идентичность (AuthorUserId/AuthorType) всегда точна (frozen в слепке).
Runtime И Эксплуатация
| Область | Требование |
|---|---|
| Health checks | /health/live; /health/ready (PostgreSQL, Kafka-producer, Elasticsearch, доступность selections — degraded, не fail); /health/startup (миграции + SEED категорий) |
| SLO/SLA | лента p99 < 300 мс (keyset + индексы); поиск p99 < 400 мс (Elasticsearch); чтение поста p99 < 200 мс; лаг консюмеров identity < 1 мин; лаг search-проекции < 1 мин |
| Dashboard | news-overview (RPS лент, publish/comment rate, лаг консюмеров, DLQ, ошибки internal-вызовов selections, размер Archived) |
| Alerts | рост 5xx selections-клиента (блокирует embed-публикации); DLQ > 0; outbox-lag > 5 мин; drift CommentsCount; лаг/ошибки Elasticsearch-индексатора (расхождение проекции news-posts) |
| Runbooks | replay DLQ консюмеров identity; застрявший outbox; «пост показывает не тот состав» (сверка SnapshotId блока ↔ snapshot в selections); ручной detach осиротевших snapshot-ов |
| Feature flags | news.subscriptions.enabled; news.promotions.enabled (гейт консюмера буста постов); news.ranking.enabled (скрыт до готовности ранжирования) |
Jobs (Pin.News.Background, по background-job-template):
comment-count-reconcile (ночной пересчёт CommentsCount), snapshot-detach-retry (надёжный detach
snapshot-ов удалённых/изменённых постов); ranking-recompute — отложен (решение №10).
Безопасность
- Actor types: guest (лента global + чтение опубликованного поста и комментариев; подписки/
комментарии/авторство — нет), user (комментарии, подписки, авторство своих постов), developer/agency
manager (авторство в scope компании +
ProjectRefs), m2m admin (категории/модерация), system (консюмеры/jobs/selections-клиент). - Permission model: канонический каталог —
identity/domain/permission.md:news.posts.view,news.posts.create-personal/create-company/create-project,news.posts.manage-own,news.posts.moderate,news.categories.manage. Eligibility публикации =actor.UserType ∈ Category.AllowedAuthorTypes∧news.posts.create-*(решение №5). Комментарии/подписки — self-scope аутентифицированного пользователя (без отдельного permission). - Sensitive data classes: PII первичных нет — авторские ФИО в реплике;
Comment.Body— публичный UGC (модерация черезnews.posts.moderate). - Audit:
CreatedAt/UpdatedAt, actor-stamping мутаций,PostVersionкак append-only история; soft-delete постов/комментариев. - Tenant isolation: ADR-0052 —
TenantId = AuthorCompanyIdдля постов застройщика/агентства; посты инвестора — вне tenant (nullable); опубликованный контент для чтения — платформенного уровня (лента global публична); record-level мутаций — поAuthorUserId(+ company scope для менеджеров); тесты изоляции обязательны дляauthoring.
Ключевые Решения
- Embed-подборки — синхронный internal API
selections(create/detach/get snapshot), не Kafka-консюмер.newsне подписан ни наpin.selections.selection-shared(адресованcrm/user-activity, не embed), ни наsnapshot-created(не существует); связьPostId ↔ SnapshotIdnewsдержит в своей транзакции (UC-SEL-004). - Иммутабельная копия подборки (INV-SEL-001): блок хранит
SnapshotId; правка живой подборки владельцем блок не меняет; единственная правка автором поста — замена блока целиком (новый snapshot +Detach(BlockReplaced)старого, порядок «создать→переключить→отвязать»). Snapshot-ы иммутабельны и не удаляются (INV-SNP-006) → старыеPostVersionрендерят исторический состав. Детали —selection-snapshot.md. - Display-данные объектов недвижимости embed-блока не заморожены — рендер добирает актуальные из
catalogпоPropertyObjectId; заморожены только состав/порядок/комментарии подборки. - Полное snapshot-версионирование (
PostVersion), не diff: пишется при каждой публикации как immutable документ. - Категории по типам авторов:
PostCategory— SEED-справочник во владенииnews; застройщик привязывает пост к компании/проектам (AuthorCompanyId,ProjectRefs), агент — к себе. - Ранжирование/рекомендации — спроектированы, реализация отложена (решение №10): проекция
PostRankingSignalsи рекомендатель — заглушки; сортировкаrecommendedвременно =date. - Поиск — Elasticsearch-проекция (ADR-0059, supersedes FTS-пункт ADR-0056):
PostSearchDocumentчерезTalent.Common.Domain.Search(SearchQuery/SearchScopeFilter/ISearchRebuildJob); PostgreSQL-tsvector/GIN не используется. Прочее по ADR-0056 simplicity-first: PostgreSQL — source of truth, Redis/ClickHouse не используются; нагрузка ≤ 1 млн users, 1–10k активных. - ADR-0052 tenancy / ADR-0053 admin / ADR-0054 (view-трекинг →
user-activity, денормализованныеViewsCount/CommentsCount— только для отображения/сортировки, пересчёт reconcile-job-ом).
Известные Ограничения И Assumptions
- Контракт блока поста (
SnapshotIdв typed-jsonb) согласован сselections(selection-snapshot.md, assumptions) — здесь финализируется. - Существование
ProjectRefs/PropertyObjectIdсинхронно не проверяется (ADR-0056); недоступные объекты недвижимости помечает выдачаcatalog; несуществующий проект — мягкая деградация подписи. - Ранжирование не действует до реализации (решение №10) —
recommended=date. - Модерация минимальная (скрытие); расширенный арбитраж — со
support(отложено). - Свежесть авторских подписей — eventual (лаг консюмеров
identity).
Связанные Документы
- Соседи:
selections/(блок-подборка, snapshot) ·catalog/(display-данные объектов недвижимости) ·identity/(авторы/компании/permissions) ·user-activity/(view-трекинг) ·crm/·promotion/(буст постов, ADR-0060); логические потребители событий —notifications, storage/CDN (ещё не спроектированы). - Решения: ADR 0052 (tenancy) /
0053 (admin) / 0054 (просмотры → user-activity) /
0056 (simplicity-first) / 0057 (dev-stats) /
0059 (Elasticsearch) / 0060 (
promotion→PromotionBoost); technical-assignment.md (№10), service-boundaries.md, glossary.md.