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

Entity / Aggregate: PostReaction + проекция PostRankingSignals (Design)

Дом полей отложенной модели ранжирования и рекомендаций постов (контекст news). Все будущие проекции (модели ответа ленты, event payload, dev-stats) ссылаются сюда и перечисляют только дельту.

РЕАЛИЗАЦИЯ ОТЛОЖЕНА (решение №10 — technical-assignment.md: «спроектировать модели; реализация отложена»). Сущности и проекции ниже — целевая модель. Сортировка recommended временно = date (INV-4), feature-flag news.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
KindAggregateRoot (простой 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
KindReplica (денормализованная read-проекция; пересчёт ночным job ranking-recompute) — не AggregateRoot
Source of truthScore/сигналы — этот контекст; сырьё просмотров — user-activity; контентные признаки — post.md
Таблицаnews.post_ranking_signals (целевая; в первой миграции отсутствует)
Tenant isolationвне tenant (платформенная витрина, ADR-0052 п.3)
Concurrencyоптимистическая блокировка — защита от гонки job × like-инкремента; Id = PostId (1:1 с постом); jsonb AffinityInputs версионируется ContractVersion

Поля

PostReactionnews.post_reactions

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPK pk_post_reactionsappИдентификатор реакции.
PostIdGuidДаFK → news.posts.id (on delete cascade); часть ux_post_reactions_userappПост-цель. Дом — post.md.
UserIdGuidДачасть ux_post_reactions_user; index ix_post_reactions_useridentity (актор, JWT sub)Кто поставил лайк.
KindReactionKindДаenum (хранится как text), varchar(16)appТип реакции; сейчас единственное значение Like.
CreatedAtDateTimeOffsetДа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) — при чтении игнорируется как нераспознанное, без падения.

PostRankingSignalsnews.post_ranking_signals

ПолеТипОбязательностьОграниченияИсточникОписание
Id (PostId)GuidДаPK pk_post_ranking_signals; = news.posts.idpost (post.md)Пост (1:1).
Views7dlongДа>= 0, default 0user-activity (assumption, см. «Источники»)Просмотры за 7 дней.
Views30dlongДа>= 0, default 0user-activityПросмотры за 30 дней — вход w1·log(Views30d+1).
LikesintДа>= 0, default 0news (PostReaction)Реплика счётчика лайков на момент пересчёта.
CommentsRatedecimal(8,4)Да>= 0, default 0newsКомментарии/просмотр (вовлечённость).
RecencyDecaydecimal(6,5)Да0.0..1.0news (вычисление)Экспоненциальное затухание по возрасту поста.
Scoredecimal(14,6)Да>= 0, default 0, index ix_post_ranking_signals_scorenews (job)Сглажённый итоговый вес; вход сортировки recommended при flag=on.
AffinityInputsPostRankingAffinityInputsDocument (jsonb)Даtyped jsonb, ContractVersionnews (snapshot из post)Контентные признаки поста для content-based рекомендаций (см. JSONB).
RecomputedAtDateTimeOffsetДаappПоследний пересчёт.

Value objects

Value objects не используются.

Модели JSONB

PostRankingAffinityInputsDocument (jsonb, колонка affinity_inputs, ContractVersion = 1)

Денормализованный снимок контентных признаков поста — чтобы content-based пересчёт не джойнил блоки/связи поста на каждый прогон. Дом самих признаков — post.md; здесь — иммутабельная проекция на момент пересчёта. Секретов/PII нет.

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
CategoryIdGuidДалог. ссылка news.post_categories.id (post-category.md)v1
BlockKindsIReadOnlyList<PostBlockKind>Даbounded (≤ 32), enum-строки; дом — post-block.mdv1
ProjectIdsIReadOnlyList<Guid>Даbounded (≤ 20); лог. ссылки проектов catalog/chessboardv1
AuthorTypePostAuthorTypeДаenum-строка; дом — post.mdv1

Правила версии: 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 батчемassumptionnews не owned трекинг; точный контракт фиксируется при реализации ранжирования
Лайкиnews (PostReaction, owned)Инкремент Likes при пересчёте / доменным событиемотложено (эта модель)
Контентные признакиpost.md: CategoryId, состав Kind блоков, ProjectRefs, AuthorTypeSnapshot в 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
Postreferencepost_reactions.post_id → posts.idGetById(PostId)cascade (удаление поста → удаление реакций)
PostPostRankingSignalsprojection 1:1Id = PostIdGetById(PostId) при пересчёте; лента — Filter без джойнаудаление поста → удаление строки сигналов
PostReactionPostRankingSignalsderivedагрегируется в Likesпересчёт job-ом / инкремент

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-1 SingleReactionPerUserОдин лайк на (PostId, UserId)уникальный индекс + идемпотентная вставка (toggle)NEWS_POST_REACTION_DUPLICATE
INV-2 CounterConsistencyPost.LikesCount изменяется атомарно с insert/delete PostReactionобработчик доменного события в том же согласованном изменениинет — механика
INV-3 SignalsPerPostРовно одна строка PostRankingSignals на пост (PK = PostId)upsert job-ом ranking-recomputeнет — детерминированный upsert
INV-4 RecommendedFallbackПри news.ranking.enabled = false: sort=recommendedsort=date (без чтения Score)маппинг сортировки в query-handler лентынет — прозрачный fallback
INV-5 ScoreBoundsScore >= 0; пересчёт полный и идемпотентный (сборка из оконных запросов)job ranking-recomputeнет — пересчёт из источников

Статуса-энума у обеих сущностей нет; единственный жизненный цикл — toggle лайка:

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

СобытиеТипКогдаSnapshot/поля
PostReactionChangedEventдоменноеLike / UnlikePostId, UserId, Kind, Delta(+1/-1)
PostLikedKafkaEventинтеграционное — топик pin.news.post-liked.v1, ContractVersion = 1Like (только при появлении потребителя)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/delete PostReaction (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
Партиционирование / retentionnone — объём в пределах числа постов (simplicity-first, ADR-0056); post_reactions каскадно чистятся при удалении поста
ConcurrencyPostReaction — уникальный индекс + идемпотентная вставка; PostRankingSignals — оптимистическая блокировка на конфликте job × like-инкремент
Permissions / PIIЛайк — self-scope (JWT sub); чтение агрегатов Likes/Score — публичная лента. UserId — Internal, не отдаётся в публичной выдаче ленты. PII нет
AuditCreatedAt реакции; RecomputedAt сигналов; воспроизводимость Score — полный пересчёт из источников
Feature flagsnews.ranking.enabled (default false) — гейтит маппинг сортировки и запуск job ranking-recompute

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

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

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