Favorites Service (Pin.Favorites)
Назначение
favorites владеет избранным (лайки объектов недвижимости + личные заметки ≤ 4000 символов) пользователей и
неавторизованных гостей (привязка к AnonymousId сессии из user-activity), а также слиянием
гостевого и аккаунтного избранного при авторизации по трём стратегиям
(device-wins / account-wins / merge). Избранное доступно владельцу со всех устройств; при входе гостевые
записи объединяются с аккаунтными по выбору пользователя.
| Поле | Значение |
|---|
| Статус | draft |
| Owning team | PIN Engagement |
| Domain | Вовлечение (engagement) |
| Data classification | Internal (личные заметки пользователя — приватные, но не PII-регулируемые; телефонов/документов нет) |
| Runtime criticality | low (недоступность не блокирует каталог/сделки; фронт деградирует до чтения кэша списка) |
Ownership
| Artifact | Owner/Rule |
|---|
| PostgreSQL schema | favorites |
| API areas | public (guest + user); internal (чтение для selections); admin-областей нет |
| Owned entities | Favorite, FavoritesMergeOperation |
| Produced events | pin.favorites.favorite-added.v1, pin.favorites.favorite-removed.v1, pin.favorites.favorites-merged.v1 |
| Consumed events | нет (триггер слияния — запрос фронта после входа; связывание сессии делает user-activity по pin.identity.sign-ins.v1) |
| Search indexes | none (списки — btree-индексы PostgreSQL, ADR-0056) |
| Analytics facts | none (действия «добавил/удалил» превращает в ActionEvent контекст user-activity) |
Ответственность И Границы
- Делает: список избранного владельца (user/сессия), добавление/удаление объекта недвижимости, установка/очистка
заметки, bulk-импорт с устройства, merge-preview и слияние по 3 стратегиям, retention гостевых записей.
- НЕ делает: display-данные объектов недвижимости (цена/название/фото — батч-запрос фронта в
catalog по списку
PropertyObjectId); выдачу AnonymousId и связывание «сессия ↔ пользователь» (user-activity, ADR-0054);
историю действий и проброс в карточку лида (user-activity → crm); подборки — отдельный контекст
selections (создание подборки из избранного — его операция, читает
избранное через internal API этого сервиса); экспорт PDF избранного (фича selections/отчётов, отложено).
- Source of truth:
Favorite, FavoritesMergeOperation.
- Реплики/проекции: не публикует snapshot-реплик; потребители получают только факты add/remove/merge
(без заметок).
Зависимости
| Зависимость | Тип | Зачем Нужна | Failure mode |
|---|
PostgreSQL (schema favorites) | db | Все таблицы + исходящие события | Полный отказ сервиса (некритично для платформы) |
| Kafka | queue | Публикация pin.favorites.* | Лайки работают; ActionEvent-лента user-activity отстаёт до восстановления |
user-activity | service (internal API) | Проверка связи сессии с пользователем при merge (INV-FAV-102); контракт AnonymousId | Слияние временно недоступно (FAVORITES_MERGE_UNAVAILABLE, retry); обычные операции не затронуты |
identity | service (JWT) | Аутентификация пользователя (валидация токена на gateway) | Гостевой режим работает по X-Anonymous-Id |
catalog | service (используется фронтом, не этим сервисом) | Display-данные объектов недвижимости списка избранного | Список отдаёт голые PropertyObjectId+заметки; карточки не отрисуются |
Redis не используется (ADR-0056: PostgreSQL-only для favorites).
Основные Сущности
| Entity | Назначение | Source Of Truth | Документ |
|---|
Favorite | Лайк объекта недвижимости владельцем (user XOR гостевая сессия) + заметка ≤ 4000 | favorites | domain/favorite.md |
FavoritesMergeOperation | Аудит/идемпотентность слияния (стратегия + счётчики) | favorites | domain/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
| Type | Name | Topic | Назначение | Link |
|---|
| Produced | FavoriteAddedKafkaEvent | pin.favorites.favorite-added.v1 | ActionEvent «добавил в избранное» → история действий/карточка лида | events/favorite-added.md |
| Produced | FavoriteRemovedKafkaEvent | pin.favorites.favorite-removed.v1 | ActionEvent «удалил из избранного» | events/favorite-removed.md |
| Produced | FavoritesMergedKafkaEvent | pin.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 (сводка) | Потребитель |
|---|
FavoriteAddedKafkaEvent | pin.favorites.favorite-added.v1 | FavoriteId | FavoriteId, UserId?, AnonymousId?, PropertyObjectId, OccurredAt | user-activity (ActionEvent «добавил») |
FavoriteRemovedKafkaEvent | pin.favorites.favorite-removed.v1 | FavoriteId | те же поля | user-activity (ActionEvent «удалил») |
FavoritesMergedKafkaEvent | pin.favorites.favorites-merged.v1 | UserId | поля FavoritesMergeOperation (стратегия + счётчики), без заметок | user-activity (ActionEvent «слил избранное») |
Гарантии для потребителя: правка заметки события не порождает (приватное действие); retention-job
гостевых записей события не шлёт; на многозаписное слияние публикуется ровно одно favorites-merged
(не серия add/remove).
Ожидает от соседей (входящие зависимости)
| Источник | Канал | Контракт / гарантия | Поведение при отсутствии/устаревании |
|---|
user-activity | синхронный internal API GET /internal/sessions/{anonymousId}/link → linkedUserId (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) |
identity | JWT (валидация на gateway), claim sub = UserId | Идентификация пользователя | Гостевой режим по X-Anonymous-Id; операции слияния требуют JWT (иначе 401) |
catalog | вызывает фронт, не этот сервис | Display-данные объектов недвижимости по списку PropertyObjectId | Список избранного отдаёт голые PropertyObjectId + заметки; мёртвые/скрытые ссылки фильтрует каталог на выдаче |
Окно eventual-consistency при входе
Слияние опирается на асинхронную цепочку: identity публикует pin.identity.sign-ins.v1 → user-activity
связывает AnonymousId ↔ UserId. Пока связывание не завершено, 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/SLA | GET /favorites p99 < 150 мс; add/remove p99 < 200 мс; merge p99 < 1 с (батчи до сотен записей) |
| Dashboard | favorites-overview: RPS операций, ошибки, merge по стратегиям, размер очереди исходящих событий |
| Alerts | отставание публикации событий > 5 мин; всплеск FAVORITES_SESSION_NOT_LINKED (подбор чужих anonymous_id); ошибки job-а cleanup |
| Runbooks | застрявшая публикация событий; повторное слияние по обращению пользователя (идемпотентность INV-FAV-101); ручной откат ошибочного merge (восстановление из бэкапа — стратегий undo нет) |
| Feature flags | favorites.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), число — конфигурация.
Связанные Документы