GET /api/v1/favorites/merge-preview — Предпросмотр слияния избранного
Возвращает пользователю после входа счётчики избранного обеих сторон (аккаунт vs текущая гостевая
сессия), список дублей и конфликтов заметок, а также флаг RequiresChoice — нужен ли диалог выбора из
трёх стратегий (../../domain/merge-strategy.md — канонический дом стратегий,
таблицы триггеров и модели FavoritesMergePreviewResult; здесь — HTTP-обвязка и алгоритм чтения).
Назначение
Первый шаг флоу слияния: сразу после входа фронт вызывает preview, чтобы решить — ничего не делать,
выполнить автослияние Merge или показать диалог трёх вариантов (предвыбран Merge) с честными
счётчиками «что будет потеряно».
| Поле | Значение |
|---|
| Сервис/контекст | favorites |
| API area | public |
| Feature group | favorites-merge |
| Статус | draft |
| Документ | docs/06-services/favorites/api/public/get-merge-preview.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (JWT обязателен — слияние имеет смысл только после входа) |
| Auth policy / Permissions | authenticated; отдельных permissions нет (self-scope) |
| Scope (RBAC) | не применяется |
| Record-level | читаются только записи UserId = actor.UserId и AnonymousId = X-Anonymous-Id, причём сессия обязана быть связана с этим пользователем (INV-FAV-102) — счётчики чужой сессии получить нельзя |
| Tenant isolation | не применяется — сущности вне tenant (ADR-0052 п.2) |
| Audit | none (чтение); отказ INV-FAV-102 логируется warning (возможный подбор AnonymousId) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/favorites/merge-preview |
| Success status | 200 |
| Idempotency header | не применяется (безопасное чтение) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
UserId | identity (JWT claim sub) | Guid | Да | JWT обязателен | self-scope |
AnonymousId | header X-Anonymous-Id | Guid | Да | != Guid.Empty | недоверенный вход; связь с пользователем проверяется через user-activity (INV-FAV-102) |
Модель ответа FavoritesMergePreviewResult
Дом модели — ../../domain/merge-strategy.md (раздел «Модель ответа
FavoritesMergePreviewResult»); здесь — проекция с источниками полей, без переопределения:
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
AccountCount | int | Да | count записей UserId = actor.UserId | — | Записей в аккаунте. |
SessionCount | int | Да | count записей AnonymousId = anonymousId | — | Записей в гостевой сессии. |
DuplicatePropertyObjectIds | IReadOnlyList<Guid> | Да | computed: пересечение по PropertyObjectId | — | Объекты недвижимости в обеих сторонах. |
NoteConflictPropertyObjectIds | IReadOnlyList<Guid> | Да | computed: дубли, где обе Note непусты и различаются | тексты заметок не возвращаются — только PropertyObjectId | Конфликты для предупреждения в диалоге. |
RequiresChoice | bool | Да | computed по таблице триггеров merge-strategy.md | — | true — нужен диалог 3 стратегий. |
AppliedOperationId | Guid? | Нет | id существующей операции слияния по паре (UserId, AnonymousId) | — | Пара уже слита (INV-FAV-101) — фронт пропускает флоу. |
Алгоритм
- Контекст и доступ. Актор — авторизованный пользователь (
UserId из JWT); гостевая сессия —
AnonymousId из заголовка X-Anonymous-Id. Если по паре (UserId, AnonymousId) операция слияния уже
существует — короткий ответ с AppliedOperationId, RequiresChoice = false (счётчики сторон при этом
считаются как обычно — сессия после слияния пуста).
- Проверка входа.
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).
- Что читаем. Две стороны (без блокировок — чтение): записи аккаунта (
UserId = actor) и записи
сессии (AnonymousId = anonymousId) — по каждой стороне достаточно PropertyObjectId, Note,
UpdatedAt. Расхождение со следующим POST /merge допустимо (merge перечитает стороны под
блокировкой).
- Бизнес-правила и маппинг.
AccountCount и SessionCount — мощности сторон;
DuplicatePropertyObjectIds — пересечение по PropertyObjectId; NoteConflictPropertyObjectIds —
дубли, где обе заметки непусты и различаются (после нормализации trim). RequiresChoice — по таблице
триггеров ../../domain/merge-strategy.md: false, если пара уже
слита; если SessionCount = 0; если AccountCount = 0; если наборы PropertyObjectId идентичны и
конфликтов заметок нет; иначе true. Автослияние здесь не выполняется — его инициирует фронт
вызовом POST /favorites/merge (единая точка мутаций).
- Результат. Модель
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 |
|---|
Unauthorized | 401 | Нет/невалиден JWT | стандартный ответ gateway | после входа |
ValidationError | 400 | Отсутствует/Guid.Empty заголовок X-Anonymous-Id | детали валидации | no retry |
FAVORITES_SESSION_NOT_LINKED | 403 | Сессия не связана с этим пользователем в user-activity (INV-FAV-102) | «Сессия не принадлежит пользователю» | no retry; warning в лог |
FAVORITES_MERGE_UNAVAILABLE | 503 | user-activity недоступен | «Слияние временно недоступно» | retry с backoff; остальные операции избранного работают |
InternalError | 500 | Отказ хранилища | generic message | retry |
Предусловия и постусловия
- Предусловие. Пользователь авторизован (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.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы