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

GET /api/v1/favorites/merge-preview — Предпросмотр слияния избранного

Возвращает пользователю после входа счётчики избранного обеих сторон (аккаунт vs текущая гостевая сессия), список дублей и конфликтов заметок, а также флаг RequiresChoice — нужен ли диалог выбора из трёх стратегий (../../domain/merge-strategy.md — канонический дом стратегий, таблицы триггеров и модели FavoritesMergePreviewResult; здесь — HTTP-обвязка и алгоритм чтения).

Назначение

Первый шаг флоу слияния: сразу после входа фронт вызывает preview, чтобы решить — ничего не делать, выполнить автослияние Merge или показать диалог трёх вариантов (предвыбран Merge) с честными счётчиками «что будет потеряно».

Metadata

ПолеЗначение
Сервис/контекстfavorites
API areapublic
Feature groupfavorites-merge
Статусdraft
Документdocs/06-services/favorites/api/public/get-merge-preview.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (JWT обязателен — слияние имеет смысл только после входа)
Auth policy / Permissionsauthenticated; отдельных permissions нет (self-scope)
Scope (RBAC)не применяется
Record-levelчитаются только записи UserId = actor.UserId и AnonymousId = X-Anonymous-Id, причём сессия обязана быть связана с этим пользователем (INV-FAV-102) — счётчики чужой сессии получить нельзя
Tenant isolationне применяется — сущности вне tenant (ADR-0052 п.2)
Auditnone (чтение); отказ INV-FAV-102 логируется warning (возможный подбор AnonymousId)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/favorites/merge-preview
Success status200
Idempotency headerне применяется (безопасное чтение)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity (JWT claim sub)GuidДаJWT обязателенself-scope
AnonymousIdheader X-Anonymous-IdGuidДа!= Guid.Emptyнедоверенный вход; связь с пользователем проверяется через user-activity (INV-FAV-102)

Модель ответа FavoritesMergePreviewResult

Дом модели — ../../domain/merge-strategy.md (раздел «Модель ответа FavoritesMergePreviewResult»); здесь — проекция с источниками полей, без переопределения:

ПолеТипОбязательностьИсточникMaskingОписание
AccountCountintДаcount записей UserId = actor.UserIdЗаписей в аккаунте.
SessionCountintДаcount записей AnonymousId = anonymousIdЗаписей в гостевой сессии.
DuplicatePropertyObjectIdsIReadOnlyList<Guid>Даcomputed: пересечение по PropertyObjectIdОбъекты недвижимости в обеих сторонах.
NoteConflictPropertyObjectIdsIReadOnlyList<Guid>Даcomputed: дубли, где обе Note непусты и различаютсятексты заметок не возвращаются — только PropertyObjectIdКонфликты для предупреждения в диалоге.
RequiresChoiceboolДаcomputed по таблице триггеров merge-strategy.mdtrue — нужен диалог 3 стратегий.
AppliedOperationIdGuid?Нетid существующей операции слияния по паре (UserId, AnonymousId)Пара уже слита (INV-FAV-101) — фронт пропускает флоу.

Алгоритм

  1. Контекст и доступ. Актор — авторизованный пользователь (UserId из JWT); гостевая сессия — AnonymousId из заголовка X-Anonymous-Id. Если по паре (UserId, AnonymousId) операция слияния уже существует — короткий ответ с AppliedOperationId, RequiresChoice = false (счётчики сторон при этом считаются как обычно — сессия после слияния пуста).
  2. Проверка входа. AnonymousId != Guid.Empty; JWT обязателен (иначе gateway вернёт 401 до операции). Связь сессии: internal-запрос user-activity GET /internal/sessions/{anonymousId}/link подтверждает, что сессия связана именно с этим пользователем (INV-FAV-102), иначе отказ FAVORITES_SESSION_NOT_LINKED — превью чужой сессии не выдаётся. Недоступность user-activity → отказ FAVORITES_MERGE_UNAVAILABLE. Существование объектов недвижимости не проверяется (INV-FAV-004).
  3. Что читаем. Две стороны (без блокировок — чтение): записи аккаунта (UserId = actor) и записи сессии (AnonymousId = anonymousId) — по каждой стороне достаточно PropertyObjectId, Note, UpdatedAt. Расхождение со следующим POST /merge допустимо (merge перечитает стороны под блокировкой).
  4. Бизнес-правила и маппинг. AccountCount и SessionCount — мощности сторон; DuplicatePropertyObjectIds — пересечение по PropertyObjectId; NoteConflictPropertyObjectIds — дубли, где обе заметки непусты и различаются (после нормализации trim). RequiresChoice — по таблице триггеров ../../domain/merge-strategy.md: false, если пара уже слита; если SessionCount = 0; если AccountCount = 0; если наборы PropertyObjectId идентичны и конфликтов заметок нет; иначе true. Автослияние здесь не выполняется — его инициирует фронт вызовом POST /favorites/merge (единая точка мутаций).
  5. Результат. Модель FavoritesMergePreviewResult; тексты заметок не возвращаются (masking: только PropertyObjectId конфликтов). HTTP 200 OK. Freshness: моментальный снимок; авторитетная проверка повторится в POST /merge под блокировками.

Диаграмма

Побочные эффекты

ТипДетали
Внешние вызовыuser-activity GET /internal/sessions/{anonymousId}/link — timeout 2 с, 1 retry; отказ → FAVORITES_MERGE_UNAVAILABLE

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
Unauthorized401Нет/невалиден JWTстандартный ответ gatewayпосле входа
ValidationError400Отсутствует/Guid.Empty заголовок X-Anonymous-Idдетали валидацииno retry
FAVORITES_SESSION_NOT_LINKED403Сессия не связана с этим пользователем в user-activity (INV-FAV-102)«Сессия не принадлежит пользователю»no retry; warning в лог
FAVORITES_MERGE_UNAVAILABLE503user-activity недоступен«Слияние временно недоступно»retry с backoff; остальные операции избранного работают
InternalError500Отказ хранилищаgeneric messageretry

Предусловия и постусловия

  • Предусловие. Пользователь авторизован (JWT), продолжает слать X-Anonymous-Id текущей сессии; сессия связана с этим пользователем в user-activity (INV-FAV-102); первичный импорт с устройства уже выполнен (import-favorites.md).
  • Постусловие. Состояние избранного не меняется (чтение); фронт получает достаточные данные, чтобы решить между «ничего не делать», «автослияние Merge» и «диалог трёх стратегий». Авторитетная проверка повторится под блокировкой в POST /favorites/merge.

Acceptance-критерии

  • Given аккаунт и сессия непусты и наборы PropertyObjectId различаются, When GET /merge-preview, Then ответ 200, RequiresChoice = true, счётчики и списки дублей/конфликтов заполнены, тексты заметок не возвращаются.
  • Given гостевая сессия пуста, When GET /merge-preview, Then RequiresChoice = false.
  • Given аккаунт пуст, сессия непуста, When GET /merge-preview, Then RequiresChoice = false (последующий merge выполнит автослияние Merge).
  • Given наборы сторон идентичны и конфликтов заметок нет, When GET /merge-preview, Then RequiresChoice = false.
  • Given пара (UserId, AnonymousId) уже слита, When GET /merge-preview, Then AppliedOperationId заполнен, RequiresChoice = false (INV-FAV-101).
  • Given сессия не связана с этим пользователем в user-activity, When GET /merge-preview, Then отказ FAVORITES_SESSION_NOT_LINKED (403), в лог — warning.
  • Given user-activity недоступен, When GET /merge-preview, Then отказ FAVORITES_MERGE_UNAVAILABLE (503); остальные операции избранного работают.
  • Given заголовок X-Anonymous-Id отсутствует или Guid.Empty, When GET /merge-preview, Then отказ ValidationError (400).

Совместимость и наблюдаемость

  • Модель ответа расширяется только additive (дом — merge-strategy.md); переименование полей — breaking.
  • Logs: debug со счётчиками; warning на FAVORITES_SESSION_NOT_LINKED.
  • Metrics: favorites_merge_preview_total{requires_choice}, favorites_merge_preview_conflicts (histogram размера NoteConflictPropertyObjectIds).
  • Traces: сквозной correlation, включая internal-вызов user-activity.
  • Dashboard/alerts/runbook — ../../README.md.

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

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

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

  • Канонический дом стратегий, таблицы триггеров и модели ответа: ../../domain/merge-strategy.md.
  • Применение стратегии: merge-favorites.md.
  • Первичный импорт до preview: import-favorites.md.
  • Контракт связи сессии: docs/06-services/user-activity/README.md (будущий путь, ADR-0054).