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

Favorites Service (Pin.Favorites)

Назначение

favorites владеет избранным (лайки объектов недвижимости + личные заметки ≤ 4000 символов) пользователей и неавторизованных гостей (привязка к AnonymousId сессии из user-activity), а также слиянием гостевого и аккаунтного избранного при авторизации по трём стратегиям (device-wins / account-wins / merge). Избранное доступно владельцу со всех устройств; при входе гостевые записи объединяются с аккаунтными по выбору пользователя.

Metadata

ПолеЗначение
Статусdraft
Owning teamPIN Engagement
DomainВовлечение (engagement)
Data classificationInternal (личные заметки пользователя — приватные, но не PII-регулируемые; телефонов/документов нет)
Runtime criticalitylow (недоступность не блокирует каталог/сделки; фронт деградирует до чтения кэша списка)

Ownership

ArtifactOwner/Rule
PostgreSQL schemafavorites
API areaspublic (guest + user); internal (чтение для selections); admin-областей нет
Owned entitiesFavorite, FavoritesMergeOperation
Produced eventspin.favorites.favorite-added.v1, pin.favorites.favorite-removed.v1, pin.favorites.favorites-merged.v1
Consumed eventsнет (триггер слияния — запрос фронта после входа; связывание сессии делает user-activity по pin.identity.sign-ins.v1)
Search indexesnone (списки — btree-индексы PostgreSQL, ADR-0056)
Analytics factsnone (действия «добавил/удалил» превращает в ActionEvent контекст user-activity)

Ответственность И Границы

  • Делает: список избранного владельца (user/сессия), добавление/удаление объекта недвижимости, установка/очистка заметки, bulk-импорт с устройства, merge-preview и слияние по 3 стратегиям, retention гостевых записей.
  • НЕ делает: display-данные объектов недвижимости (цена/название/фото — батч-запрос фронта в catalog по списку PropertyObjectId); выдачу AnonymousId и связывание «сессия ↔ пользователь» (user-activity, ADR-0054); историю действий и проброс в карточку лида (user-activitycrm); подборки — отдельный контекст selections (создание подборки из избранного — его операция, читает избранное через internal API этого сервиса); экспорт PDF избранного (фича selections/отчётов, отложено).
  • Source of truth: Favorite, FavoritesMergeOperation.
  • Реплики/проекции: не публикует snapshot-реплик; потребители получают только факты add/remove/merge (без заметок).

Зависимости

ЗависимостьТипЗачем НужнаFailure mode
PostgreSQL (schema favorites)dbВсе таблицы + исходящие событияПолный отказ сервиса (некритично для платформы)
KafkaqueueПубликация pin.favorites.*Лайки работают; ActionEvent-лента user-activity отстаёт до восстановления
user-activityservice (internal API)Проверка связи сессии с пользователем при merge (INV-FAV-102); контракт AnonymousIdСлияние временно недоступно (FAVORITES_MERGE_UNAVAILABLE, retry); обычные операции не затронуты
identityservice (JWT)Аутентификация пользователя (валидация токена на gateway)Гостевой режим работает по X-Anonymous-Id
catalogservice (используется фронтом, не этим сервисом)Display-данные объектов недвижимости списка избранногоСписок отдаёт голые PropertyObjectId+заметки; карточки не отрисуются

Redis не используется (ADR-0056: PostgreSQL-only для favorites).

Основные Сущности

EntityНазначениеSource Of TruthДокумент
FavoriteЛайк объекта недвижимости владельцем (user XOR гостевая сессия) + заметка ≤ 4000favoritesdomain/favorite.md
FavoritesMergeOperationАудит/идемпотентность слияния (стратегия + счётчики)favoritesdomain/merge-strategy.md

ER-обзор

API (карта областей; детали — api/)

Все операции — self-scope: владелец определяется JWT (user) либо заголовком X-Anonymous-Id (гость); у авторизованного пользователя приоритет у JWT.

  • Public:
    • GET /favorites — список избранного владельца (заметки, CreatedAt desc; display-данные объектов недвижимости фронт добирает из catalog батчем) → api/public/get-my-favorites.md;
    • POST /favorites — добавить объект недвижимости (propertyObjectId, note?); идемпотентно по INV-FAV-002 → api/public/add-favorite.md;
    • DELETE /favorites/{propertyObjectId} — удалить из избранного → api/public/remove-favorite.md;
    • PUT /favorites/{propertyObjectId}/note — установить/очистить заметку (≤ 4000) → api/public/update-favorite-note.md;
    • POST /favorites/import — bulk-импорт propertyObjectId[] с устройства (идемпотентен — дубли пропускаются) → api/public/import-favorites.md;
    • GET /favorites/merge-preview — счётчики/конфликты сторон после входа → api/public/get-merge-preview.md;
    • POST /favorites/merge — применить стратегию слияния → api/public/merge-favorites.md (алгоритм — domain/merge-strategy.md).
  • Admin: not applicable (модерация заметок не требуется; заметки приватны).
  • Internal:
    • GET /internal/favorites?userId= — чтение избранного пользователя контекстом selections (создание подборки из текущего избранного, m2m) → api/internal/list-favorites.md;
    • POST /internal/favorites/status — пакетный 3-state статус избранного по парам (userId ∈ {агент, инвестор}) × propertyObjectId[] для обогащения выдачи агента контекстом crm (m2m, permission favorites.status.read-internal, делегированная авторизация по активному лиду; Note не отдаётся) → api/internal/get-favorite-status.md.
  • Callbacks: not applicable.

Events И Consumers

TypeNameTopicНазначениеLink
ProducedFavoriteAddedKafkaEventpin.favorites.favorite-added.v1ActionEvent «добавил в избранное» → история действий/карточка лидаevents/favorite-added.md
ProducedFavoriteRemovedKafkaEventpin.favorites.favorite-removed.v1ActionEvent «удалил из избранного»events/favorite-removed.md
ProducedFavoritesMergedKafkaEventpin.favorites.favorites-merged.v1Факт слияния (стратегия+счётчики) в историю действийevents/favorites-merged.md
ConsumedНет consumed-событий (обоснование и условия пересмотра)consumers/README.md

Consumers событий — контекст user-activity (будущий док: docs/06-services/user-activity/consumers/).

Кросс-сервисные контракты

Предоставляет (исходящие события)

Все события — ContractVersion = 1, доставка at-least-once (потребитель обязан быть идемпотентным); порядок гарантирован только в пределах ключа партиционирования; Note никогда не входит в payload.

СобытиеТопикКлюч партиционированияPayload (сводка)Потребитель
FavoriteAddedKafkaEventpin.favorites.favorite-added.v1FavoriteIdFavoriteId, UserId?, AnonymousId?, PropertyObjectId, OccurredAtuser-activity (ActionEvent «добавил»)
FavoriteRemovedKafkaEventpin.favorites.favorite-removed.v1FavoriteIdте же поляuser-activity (ActionEvent «удалил»)
FavoritesMergedKafkaEventpin.favorites.favorites-merged.v1UserIdполя FavoritesMergeOperation (стратегия + счётчики), без заметокuser-activity (ActionEvent «слил избранное»)

Гарантии для потребителя: правка заметки события не порождает (приватное действие); retention-job гостевых записей события не шлёт; на многозаписное слияние публикуется ровно одно favorites-merged (не серия add/remove).

Ожидает от соседей (входящие зависимости)

ИсточникКаналКонтракт / гарантияПоведение при отсутствии/устаревании
user-activityсинхронный internal API GET /internal/sessions/{anonymousId}/linklinkedUserId (timeout 2 с, 1 retry)Подтверждение связи гостевой сессии с пользователем (INV-FAV-102); используется только merge-preview/mergeНе связана → FAVORITES_SESSION_NOT_LINKED (403); недоступен → FAVORITES_MERGE_UNAVAILABLE (503), прочие операции работают
user-activityзаголовок X-Anonymous-Id (выдаётся клиенту)Идентификатор гостевой сессии для record-level владельца (ADR-0054)Нет валидного заголовка и нет JWT → FAVORITES_OWNER_UNRESOLVED (400)
identityJWT (валидация на gateway), claim sub = UserIdИдентификация пользователяГостевой режим по X-Anonymous-Id; операции слияния требуют JWT (иначе 401)
catalogвызывает фронт, не этот сервисDisplay-данные объектов недвижимости по списку PropertyObjectIdСписок избранного отдаёт голые PropertyObjectId + заметки; мёртвые/скрытые ссылки фильтрует каталог на выдаче

Окно eventual-consistency при входе

Слияние опирается на асинхронную цепочку: identity публикует pin.identity.sign-ins.v1user-activity связывает AnonymousIdUserId. Пока связывание не завершено, merge-preview/merge возвращают FAVORITES_SESSION_NOT_LINKED — фронт повторяет после короткой паузы. Обычные операции избранного (add / remove / list / note / import) от этого окна не зависят и доступны сразу. favorites не хранит и не реплицирует объекты недвижимости, пользователей и сессии — только логические ссылки по id, поэтому устаревание данных у соседей не нарушает его консистентность.

Runtime И Эксплуатация

ОбластьТребование
Health checks/health/live; /health/ready (PostgreSQL, Kafka-producer); /health/startup (миграции применены)
SLO/SLAGET /favorites p99 < 150 мс; add/remove p99 < 200 мс; merge p99 < 1 с (батчи до сотен записей)
Dashboardfavorites-overview: RPS операций, ошибки, merge по стратегиям, размер очереди исходящих событий
Alertsотставание публикации событий > 5 мин; всплеск FAVORITES_SESSION_NOT_LINKED (подбор чужих anonymous_id); ошибки job-а cleanup
Runbooksзастрявшая публикация событий; повторное слияние по обращению пользователя (идемпотентность INV-FAV-101); ручной откат ошибочного merge (восстановление из бэкапа — стратегий undo нет)
Feature flagsfavorites.merge-dialog.enabled (до готовности фронта — автослияние Merge всегда)

Безопасность

  • Actor types: guest (по X-Anonymous-Id), user (JWT self-scope), m2m (selections — чтение списка; crm — пакетный статус избранного), system (background job).
  • Permission model: self-scope без явных permissions; привилегированные доступы: favorites.merge-log.view (служба заботы, журнал слияний) и internal-m2m favorites.status.read-internal (контекст crm, пакетный статус избранного). Клиенты internal-области — allowlist на gateway: pin-selections (чтение списка) и pin-crm (статус избранного); каталог permissions — ../identity/domain/permission.md.
  • Delegated authorization: право агента видеть избранное инвестора проверяет вызывающий контекст crm по активному лиду/фиксации (паттерн UC-ACT-004); favorites эту связь не перепроверяет — доверяет переданным userId от m2m-клиента из allowlist.
  • Sensitive data classes: Note — приватная заметка владельца (не логируется, не попадает в события, не отдаётся ни в одном internal-ответе — ни list-favorites, ни get-favorite-status, в т.ч. кросс-владельца); недоступна агентам/менеджерам; PII нет.
  • Audit: CreatedAt/UpdatedAt на записях; слияния — append-only FavoritesMergeOperation; add/remove — события в user-activity.
  • Tenant isolation: сущности вне tenant (ADR-0052 п.2 — данные инвестора/гостя); обязательный record-level фильтр по UserId/AnonymousId в каждой операции (INV-FAV-005); тесты изоляции обязательны.
  • Trusted/untrusted boundary: X-Anonymous-Id — недоверенный вход (подбор Guid бесполезен: чтение чужой гостевой сессии требует знания её id; merge дополнительно проверяет связь INV-FAV-102); PropertyObjectId — недоверенный (существование не гарантируется, INV-FAV-004).

Известные Ограничения И Assumptions

  • Существование PropertyObjectId не проверяется синхронно (ADR-0056); мёртвые ссылки фильтрует выдача каталога.
  • Контракт X-Anonymous-Id и internal API связи сессии — assumption до написания ТЗ user-activity (будущий путь docs/06-services/user-activity/README.md).
  • Экспорт избранного в PDF (фиче-матрицы) — вне scope этого контекста; вернётся с отчётами/selections.
  • Undo слияния не предусмотрен: диалог фронта предупреждает о потере данных для DeviceWins/AccountWins; рекомендация — Merge (без потерь).
  • Гостевые записи живут 365 дней с последней активности (retention-job), число — конфигурация.

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