Entity / Aggregate: PostReaction + проекция PostRankingSignals (Design)
Дом полей отложенной модели ранжирования и рекомендаций постов (контекст news). Все будущие проекции
(модели ответа ленты, event payload, dev-stats) ссылаются сюда и перечисляют только дельту.
РЕАЛИЗАЦИЯ ОТЛОЖЕНА (решение №10 — technical-assignment.md: «спроектировать модели; реализация отложена»). Сущности и проекции ниже — целевая модель. Сортировка
recommendedвременно =date(INV-4), feature-flagnews.ranking.enabled = false. Таблицnews.post_reactions/news.post_ranking_signalsв первой миграции нет; события и консюмеры вводятся только при появлении реального потребителя/источника.
Назначение
Цель модели — ранжировать глобальную ленту постов и давать персональные рекомендации на основе истории
просмотров постов, лайков, типов постов и содержимого (категория / состав блоков / привязка к проектам).
Owning-контекст — news (source of truth лайков и вычисленного Score). Signals-источники — чужие
контексты (просмотры — user-activity, ADR-0054), поэтому PostRankingSignals — денормализованная
read-проекция, а не корень бизнес-инвариантов.
Simplicity-first (ADR-0056):
PostgreSQL-only, без ML-инфраструктуры и отдельного рекомендательного сервиса на старте; стартовая
реализация при включении флага — эвристика на оконных функциях PostgreSQL. ClickHouse не вводится
(многомерная аналитика — только dev-stats).
Целевой алгоритм (эскиз, отложен)
Score = w1·log(Views30d + 1)·RecencyDecay -- популярность × свежесть
+ w2·Likes -- социальный сигнал (PostReaction)
+ w3·CommentsRate -- вовлечённость
+ w4·(Promotion + AuthorTrust) -- продвижение / доверие автору
где Promotion = normalize(Post.PromotionBoost) -- Σ BoostWeight активных покупок (консюмер promotion-boost)
Promotion — нормализованный Post.PromotionBoost (дом — post.md; Σ BoostWeight активных
покупок, наполняется консюмером promotion-boost.md, ADR-0060);
normalize → [0,1] (конфиг). Персональные рекомендации — content-based: аффинность пользователя по
категориям/типам просмотренных постов (профиль интересов из user-activity) × свежие посты этих категорий/
авторов, с diversity-ограничением. Веса w1..w4 и параметры затухания — конфиг сервиса (не хардкод).
Классификация
PostReaction (лайк, owned) — отложено
| Поле | Значение |
|---|---|
| Контекст | news |
| Kind | AggregateRoot (простой toggle-like: like = insert, unlike = delete строки) |
| Source of truth | этот контекст |
| Таблица | news.post_reactions (целевая; в первой миграции отсутствует) |
| Tenant isolation | вне tenant (ADR-0052 п.2 — пользовательские данные); изоляция record-level по UserId |
| Concurrency | гонку снимает уникальный индекс ux_post_reactions_user + идемпотентная вставка (toggle); статуса и jsonb нет |
PostRankingSignals (проекция) — отложено
| Поле | Значение |
|---|---|
| Контекст | news |
| Kind | Replica (денормализованная read-проекция; пересчёт ночным job ranking-recompute) — не AggregateRoot |
| Source of truth | Score/сигналы — этот контекст; сырьё просмотров — user-activity; контентные признаки — post.md |
| Таблица | news.post_ranking_signals (целевая; в первой миграции отсутствует) |
| Tenant isolation | вне tenant (платформенная витрина, ADR-0052 п.3) |
| Concurrency | оптимистическая блокировка — защита от гонки job × like-инкремента; Id = PostId (1:1 с постом); jsonb AffinityInputs версионируется ContractVersion |
Поля
PostReaction — news.post_reactions
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK pk_post_reactions | app | Идентификатор реакции. |
PostId | Guid | Да | FK → news.posts.id (on delete cascade); часть ux_post_reactions_user | app | Пост-цель. Дом — post.md. |
UserId | Guid | Да | часть ux_post_reactions_user; index ix_post_reactions_user | identity (актор, JWT sub) | Кто поставил лайк. |
Kind | ReactionKind | Да | enum (хранится как text), varchar(16) | app | Тип реакции; сейчас единственное значение Like. |
CreatedAt | DateTimeOffset | Да | server clock (now()) | app | Момент лайка. |
unique (PostId, UserId) (ux_post_reactions_user) — один лайк на пользователя (INV-1). Инкремент целевого
денорм-счётчика Post.LikesCount (добавляется в post.md при вводе лайков; сейчас отсутствует) —
в том же согласованном изменении (INV-2).
Enum ReactionKind (хранится как text)
| Значение | Описание | Fallback |
|---|---|---|
Like | Единственный тип на старте. | Неизвестное значение (будущие Bookmark/эмодзи, additive) — при чтении игнорируется как нераспознанное, без падения. |
PostRankingSignals — news.post_ranking_signals
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
Id (PostId) | Guid | Да | PK pk_post_ranking_signals; = news.posts.id | post (post.md) | Пост (1:1). |
Views7d | long | Да | >= 0, default 0 | user-activity (assumption, см. «Источники») | Просмотры за 7 дней. |
Views30d | long | Да | >= 0, default 0 | user-activity | Просмотры за 30 дней — вход w1·log(Views30d+1). |
Likes | int | Да | >= 0, default 0 | news (PostReaction) | Реплика счётчика лайков на момент пересчёта. |
CommentsRate | decimal(8,4) | Да | >= 0, default 0 | news | Комментарии/просмотр (вовлечённость). |
RecencyDecay | decimal(6,5) | Да | 0.0..1.0 | news (вычисление) | Экспоненциальное затухание по возрасту поста. |
Score | decimal(14,6) | Да | >= 0, default 0, index ix_post_ranking_signals_score | news (job) | Сглажённый итоговый вес; вход сортировки recommended при flag=on. |
AffinityInputs | PostRankingAffinityInputsDocument (jsonb) | Да | typed jsonb, ContractVersion | news (snapshot из post) | Контентные признаки поста для content-based рекомендаций (см. JSONB). |
RecomputedAt | DateTimeOffset | Да | — | app | Последний пересчёт. |
Value objects
Value objects не используются.
Модели JSONB
PostRankingAffinityInputsDocument (jsonb, колонка affinity_inputs, ContractVersion = 1)
Денормализованный снимок контентных признаков поста — чтобы content-based пересчёт не джойнил блоки/связи поста на каждый прогон. Дом самих признаков — post.md; здесь — иммутабельная проекция на момент пересчёта. Секретов/PII нет.
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
ContractVersion | int | Да | = 1 | v1 |
CategoryId | Guid | Да | лог. ссылка news.post_categories.id (post-category.md) | v1 |
BlockKinds | IReadOnlyList<PostBlockKind> | Да | bounded (≤ 32), enum-строки; дом — post-block.md | v1 |
ProjectIds | IReadOnlyList<Guid> | Да | bounded (≤ 20); лог. ссылки проектов catalog/chessboard | v1 |
AuthorType | PostAuthorType | Да | enum-строка; дом — post.md | v1 |
Правила версии: additive-поля — той же версией (nullable/default); смена семантики — ContractVersion + 1
- migration-reader.
object/«список ids в jsonb» без типа запрещены (строгая типизация).
Источники сигналов (assumptions до реализации)
| Сигнал | Источник | Механизм (целевой) | Статус контракта |
|---|---|---|---|
| Просмотры постов | user-activity (владелец трекинга, ADR-0054); фронт шлёт ActionEvent «post-view» | news либо консюмит будущий агрегат pin.user-activity.post-view-ended.v1 (по аналогии property-object-view-ended.md), либо тянет агрегаты через internal API user-activity батчем | assumption — news не owned трекинг; точный контракт фиксируется при реализации ранжирования |
| Лайки | news (PostReaction, owned) | Инкремент Likes при пересчёте / доменным событием | отложено (эта модель) |
| Контентные признаки | post.md: CategoryId, состав Kind блоков, ProjectRefs, AuthorType | Snapshot в AffinityInputs | внутри контекста |
Продвижение (терм Promotion) | promotion → news-консюмер | Денормализованный Post.PromotionBoost (Σ BoostWeight активных покупок), наполняет promotion-boost.md; Score читает нормализованным | draft — поле/консюмер реальны (ADR-0060); чтение в Score отложено с ранжированием |
Профиль интересов пользователя (аффинность по категориям/типам просмотренных постов) собирается из
user-activity — событие action-tracked.md уже помечает
news будущим кандидатом-консюмером (вход рекомендаций, ADR-0054).
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
Post | reference | post_reactions.post_id → posts.id | GetById(PostId) | cascade (удаление поста → удаление реакций) |
Post ↔ PostRankingSignals | projection 1:1 | Id = PostId | GetById(PostId) при пересчёте; лента — Filter без джойна | удаление поста → удаление строки сигналов |
PostReaction → PostRankingSignals | derived | агрегируется в Likes | пересчёт job-ом / инкремент | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-1 SingleReactionPerUser | Один лайк на (PostId, UserId) | уникальный индекс + идемпотентная вставка (toggle) | NEWS_POST_REACTION_DUPLICATE |
| INV-2 CounterConsistency | Post.LikesCount изменяется атомарно с insert/delete PostReaction | обработчик доменного события в том же согласованном изменении | нет — механика |
| INV-3 SignalsPerPost | Ровно одна строка PostRankingSignals на пост (PK = PostId) | upsert job-ом ranking-recompute | нет — детерминированный upsert |
| INV-4 RecommendedFallback | При news.ranking.enabled = false: sort=recommended ≡ sort=date (без чтения Score) | маппинг сортировки в query-handler ленты | нет — прозрачный fallback |
| INV-5 ScoreBounds | Score >= 0; пересчёт полный и идемпотентный (сборка из оконных запросов) | job ranking-recompute | нет — пересчёт из источников |
Статуса-энума у обеих сущностей нет; единственный жизненный цикл — toggle лайка:
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
PostReactionChangedEvent | доменное | Like / Unlike | PostId, UserId, Kind, Delta(+1/-1) |
PostLikedKafkaEvent | интеграционное — топик pin.news.post-liked.v1, ContractVersion = 1 | Like (только при появлении потребителя) | PostReactionId, PostId, UserId, Kind, LikesCount, CreatedAt |
PostReactionChangedEvent при Like/Unlike инкрементит/декрементит Post.LikesCount в том же согласованном
изменении (INV-2, паттерн денормализации каталога). Интеграционное PostLikedKafkaEvent
(топик pin.news.post-liked.v1) публиковалось бы через транзакционный outbox атомарно со сменой состояния,
но не публикуется, пока нет реального consumer-а. PostRankingSignals — конечная точка данных, событий
не поднимает.
Влияние на API (сейчас)
GET /api/v1/posts?sort=recommended принимается, но PostSortField.Recommended прозрачно маппится на
Date (INV-4); при news.ranking.enabled = on — на Score (ix_post_ranking_signals_score). Реальных
таблиц post_reactions/post_ranking_signals в первой миграции нет. Дом контракта ленты —
list-global-feed.md; выдача — постраничная лента.
Согласованность пересчёта (целевая, отложено)
- Score — eventual.
PostRankingSignals.Scoreобновляется ночнымranking-recompute(не realtime): лентаrecommendedприflag=onпоказываетScoreпоследнего прогона. Лайк отражается вLikesсразу (денорм-счётчик), но во влияние наScore— со следующего пересчёта. - Полный идемпотентный upsert. Пересчёт собирает сигналы из источников (просмотры —
user-activity, лайки —PostReaction, контент —AffinityInputs) и делает детерминированный upsert на пост (PK = PostId, INV-3/INV-5): повторный/частичный прогон даёт то же состояние, дрейф не накапливается. - Гонка job × лайк. Оптимистическая блокировка на
PostRankingSignals: инкрементLikesво время прогона либо учитывается, либо доедет следующим пересчётом — потери счётчика нет (INV-2 держитPost.LikesCountатомарно с реакцией независимо от job).
Acceptance Criteria (целевые, отложено)
- Fallback выключенного флага. Дано
news.ranking.enabled = false; когда запрошенsort=recommended; тогда выдача эквивалентнаsort=date,Score/таблицы сигналов не читаются (INV-4). - Ранжирование включено. Дано
flag=onи заполненныеPostRankingSignals; когдаsort=recommended; тогда сортировка поScore desc(ix_post_ranking_signals_score). - Один лайк на пользователя. Дано пользователь уже лайкнул пост; когда повторный
Like; тогда идемпотентный no-op (toggle), дубликата нет (INV-1,NEWS_POST_REACTION_DUPLICATEпри прямом инсерте). - Счётчик согласован. Дано
Like/Unlike; когда операция; тогдаPost.LikesCountменяется атомарно с insert/deletePostReaction(INV-2). - Unknown reaction. Дано будущее значение
ReactionKind(additive, напр.Bookmark); когда чтение старым читателем; тогда значение игнорируется как нераспознанное, без падения. - Каскад удаления поста. Дано пост удаляется; когда каскад; тогда его
PostReactionи строкаPostRankingSignalsудаляются (FK cascade / 1:1).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | post_reactions: PK pk_post_reactions; unique ux_post_reactions_user (post_id, user_id); btree ix_post_reactions_user (user_id). post_ranking_signals: PK pk_post_ranking_signals (id = post_id); btree ix_post_ranking_signals_score (score desc) — сортировка recommended при flag=on |
| Партиционирование / retention | none — объём в пределах числа постов (simplicity-first, ADR-0056); post_reactions каскадно чистятся при удалении поста |
| Concurrency | PostReaction — уникальный индекс + идемпотентная вставка; PostRankingSignals — оптимистическая блокировка на конфликте job × like-инкремент |
| Permissions / PII | Лайк — self-scope (JWT sub); чтение агрегатов Likes/Score — публичная лента. UserId — Internal, не отдаётся в публичной выдаче ленты. PII нет |
| Audit | CreatedAt реакции; RecomputedAt сигналов; воспроизводимость Score — полный пересчёт из источников |
| Feature flags | news.ranking.enabled (default false) — гейтит маппинг сортировки и запуск job ranking-recompute |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: Post
- API (1): GET /api/v1/posts — Глобальная лента + поиск
- Use Cases (1): UC-NWS-005. Поиск постов (Elasticsearch)
- События (1): События сервиса news: карта топиков и отклонённые события/консюмеры
- Консюмеры (1): Consumer: Promotion Boost (pin-news-promotion-boost)
- adr (1): ADR-0060: Продвижение — отдельный bounded context promotion (полиморфный таргет); catalog и news — консюмеры буста
- registries (1): Реестр сущностей PIN
Связанные документы
- Дом полей поста — post.md (
PromotionBoost,ProjectRefs,AuthorType,LikesCount); границыnews— ../README.md; сортировка ленты — list-global-feed.md. - Продвижение (терм
Promotion): консюмер ../consumers/promotion-boost.md, событие promotion-purchase-changed.md. - Просмотры — user-activity/README.md, action-tracked.md; аналогия каталога — ranking-rule.md.
- Решение №10 (technical-assignment.md); ADR 0060 / 0056 / 0054 / 0052.