POST /api/v1/favorites/merge — Применить стратегию слияния избранного
Выполняет слияние гостевого избранного текущей сессии (AnonymousId) с избранным аккаунта
(UserId) по выбранной стратегии DeviceWins / AccountWins / Merge (рекомендация и предвыбор —
Merge). Канонический дом стратегий, таблиц поведения, правил слияния заметок (LastWriterWins),
сущности FavoritesMergeOperation и полного алгоритма слияния —
../../domain/merge-strategy.md; этот файл — его HTTP-обвязка
(перечисляется только дельта).
Назначение
Финальный шаг флоу авторизации на странице избранного: после GET /favorites/merge-preview
(get-merge-preview.md) фронт либо показывает диалог трёх вариантов, либо для
вырожденных случаев вызывает merge сразу со стратегией Merge без диалога. Операция идемпотентна на
пару «пользователь + сессия» (INV-FAV-101): повтор возвращает сохранённый результат первой операции.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | favorites |
| API area | public |
| Feature group | favorites-merge |
| Статус | draft |
| Документ | docs/06-services/favorites/api/public/merge-favorites.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (JWT обязателен — сливать можно только в собственный аккаунт) |
| Auth policy / Permissions | authenticated; отдельных permissions нет (self-scope) |
| Scope (RBAC) | не применяется |
| Record-level | мутируются только записи UserId = actor.UserId и AnonymousId = X-Anonymous-Id; сессия обязана быть связана с актором в user-activity (INV-FAV-102); утечка на чужие записи исключена предикатами (INV-FAV-105) |
| Tenant isolation | не применяется — сущности вне tenant (ADR-0052 п.2) |
| Audit | append-only FavoritesMergeOperation + FavoritesMergedKafkaEvent; журнал слияний доступен службе заботы по permission favorites.merge-log.view |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/favorites/merge |
| Success status | 200 (в т.ч. идемпотентный повтор — возвращается результат существующей операции) |
| Idempotency header | не применяется — идемпотентность по бизнес-ключу (UserId, AnonymousId), INV-FAV-101 + unique ux_merge_ops_user_anonymous |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserId | identity (JWT claim sub) | Guid | Да | JWT обязателен | приёмник слияния — только сам актор |
AnonymousId | header X-Anonymous-Id | Guid | Да | != Guid.Empty; связь с актором проверяется в user-activity (INV-FAV-102) | недоверенный вход — подбор чужого Guid отбивается 403 |
Strategy | body strategy | FavoritesMergeStrategy | Да | ∈ {DeviceWins, AccountWins, Merge} (INV-FAV-103); строковое значение enum | выбор пользователя из диалога; для автослияния фронт передаёт Merge |
IsAutomatic не принимается из body — вычисляется сервером: true, если состояние сторон подпадает
под вырожденный случай таблицы триггеров merge-strategy.md (тогда независимо от присланного значения
применяется Merge); иначе false.
Модель ответа FavoritesMergeResult
Маппинг 1:1 из полей созданной (или существующей при повторе) FavoritesMergeOperation — дом полей:
../../domain/merge-strategy.md.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
OperationId | Guid | Да | FavoritesMergeOperation.Id | — | Идентификатор операции слияния. |
Strategy | FavoritesMergeStrategy | Да | FavoritesMergeOperation.Strategy | — | Фактически применённая стратегия. |
IsAutomatic | bool | Да | FavoritesMergeOperation.IsAutomatic | — | Слияние без диалога (вырожденный случай). |
MovedCount | int | Да | FavoritesMergeOperation.MovedCount | — | Перенесено записей сессии в аккаунт. |
RemovedFromAccountCount | int | Да | FavoritesMergeOperation.RemovedFromAccountCount | — | Удалено записей аккаунта (только DeviceWins). |
RemovedFromSessionCount | int | Да | FavoritesMergeOperation.RemovedFromSessionCount | — | Удалено записей сессии (дубли Merge; всё — AccountWins). |
NotesMergedCount | int | Да | FavoritesMergeOperation.NotesMergedCount | — | Заметок перенесено/перезаписано (правила LastWriterWins). |
TotalCountAfter | int | Да | FavoritesMergeOperation.TotalCountAfter | — | Итоговый размер избранного аккаунта. |
WasAlreadyApplied | bool | Да | computed: попадание в идемпотентную ветку | — | true — вернулся результат ранее выполненной операции (INV-FAV-101). |
Тексты заметок и списки PropertyObjectId в ответ не включаются (заметки приватны; итоговый список фронт читает
отдельным GET /favorites).
Алгоритм
Канонический алгоритм слияния — раздел «Алгоритм слияния» в
../../domain/merge-strategy.md; ниже — те же шаги в привязке к
HTTP-обвязке (дельта: определение IsAutomatic, HTTP-статусы):
- Контекст и доступ. Актор =
UserIdиз JWT; гостевая сессия —AnonymousIdиз заголовка. Если по паре (UserId,AnonymousId) операция слияния уже существует → вернуть её результат (WasAlreadyApplied = true), HTTP200, без мутаций (INV-FAV-101). - Проверка входа.
Strategy∈ enum (INV-FAV-103), иначеValidationError;AnonymousId != Guid.Empty. Internal-запросuser-activityGET /internal/sessions/{anonymousId}/linkподтверждает связь сессии с актором (INV-FAV-102), иначе отказFAVORITES_SESSION_NOT_LINKED; недоступность — отказFAVORITES_MERGE_UNAVAILABLE. Объекты недвижимости не проверяются (INV-FAV-004). - Что читаем. Обе стороны читаются под блокировкой (параллельный add не теряется): записи сессии
(
AnonymousId = actor session) и записи аккаунта (UserId = actor); строится множество дублей поPropertyObjectId. - Бизнес-правила и маппинг. Определить
IsAutomatic: если сессия пуста → операция с нулевыми счётчиками,IsAutomatic = true,Strategy = Merge; если аккаунт пуст или наборы идентичны без конфликтов заметок → форсироватьStrategy = Merge,IsAutomatic = true; иначеIsAutomatic = false, применяется присланнаяStrategy. Далее по таблице поведения merge-strategy.md вычислитьtoDelete/toMove/noteUpdates(LastWriterWins поUpdatedAtдля конфликтующих заметок дублейMerge) и счётчики полейFavoritesMergeOperation(TotalCountAfter = |A| - removedFromAccount + moved). - Что меняется. В порядке из-за partial-unique
ux_favorites_user_property_object: (1) удаляютсяtoDelete; (2) переносятсяtoMove(UserId := actor,AnonymousId := null,UpdatedAt := now); (3) обновляются заметки поnoteUpdates; (4) создаётся записьFavoritesMergeOperation. - Какие факты/события фиксируются. Публикуется
FavoritesMergedKafkaEvent(ContractVersion = 1, topicpin.favorites.favorites-merged.v1,AggregateId = operation.Id,AggregateType = "FavoritesMergeOperation"; payload — поля операции, без заметок). Потребитель —user-activity(ActionEvent «слил избранное»). Отдельные события add/remove на каждую перенесённую запись не публикуются — факт слияния атомарный. - Согласованность. Мутации записей, запись операции и публикация события фиксируются в одном
согласованном изменении (INV-FAV-104). Гонка параллельных merge → unique
ux_merge_ops_user_anonymousоткатывает проигравшего; его повтор попадает в идемпотентную ветку шага 1. - Результат. Модель
FavoritesMergeResult(маппинг из операции,WasAlreadyApplied = false), HTTP200 OK. Фронт после успеха вызываетGET /favoritesза итоговым списком аккаунта.
Диаграмма
Полная sequence-диаграмма флоу preview → диалог → merge — в ../../domain/merge-strategy.md. Здесь — HTTP-обвязка операции:
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | FavoritesMergedKafkaEvent / pin.favorites.favorites-merged.v1 / key = UserId; один на операцию (при идемпотентном повторе не публикуется) |
| Внешние вызовы | user-activity GET /internal/sessions/{anonymousId}/link — timeout 2 с, 1 retry |
| Проекции / поиск | none (ADR-0056) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
Unauthorized | 401 | Нет/невалиден JWT | стандартный ответ gateway | после входа |
ValidationError | 400 | strategy вне enum (INV-FAV-103); X-Anonymous-Id отсутствует/Guid.Empty | детали валидации | no retry |
FAVORITES_SESSION_NOT_LINKED | 403 | Сессия не связана с актором (INV-FAV-102) | «Сессия не принадлежит пользователю» | no retry; warning в лог (алерт на всплеск — README) |
FAVORITES_MERGE_UNAVAILABLE | 503 | user-activity недоступен | «Слияние временно недоступно» | retry с backoff — идемпотентно |
InternalError | 500 | Отказ хранилища; откат по unique-гонке, исчерпан retry | generic message | retry — попадёт в идемпотентную ветку |
Undo не предусмотрен (../../README.md, Известные Ограничения): диалог фронта предупреждает
о потере данных для DeviceWins/AccountWins счётчиками из preview; восстановление — только из бэкапа
(runbook).
Предусловия и постусловия
- Предусловие. Пользователь авторизован (JWT), передаёт
X-Anonymous-Idтекущей сессии; сессия связана с актором вuser-activity(INV-FAV-102);Strategy∈ enum; выполненGET /merge-preview(для диалога). - Постусловие (первое слияние). Избранное аккаунта приведено к итоговому набору выбранной стратегии;
создана запись
FavoritesMergeOperation(аудит + идемпотентность на пару); опубликован одинpin.favorites.favorites-merged.v1(key =UserId); гостевые записи сессии либо перенесены во владельцаUserId, либо удалены — по стратегии. - Постусловие (идемпотентный повтор). Мутаций нет, событие не публикуется, возвращаются сохранённые
счётчики первой операции (
WasAlreadyApplied = true, INV-FAV-101).
Acceptance-критерии
- Given непустые различающиеся стороны и
strategy = Merge, WhenPOST /merge, Then записи сессии перенесены во владельцаUserId, дубли дедуплицированы, заметки — по LastWriterWins, созданаFavoritesMergeOperation, опубликованFavoritesMergedKafkaEvent(key =UserId), ответ200,WasAlreadyApplied = false. - Given
strategy = DeviceWins, WhenPOST /merge, Then записи аккаунта (кроме дублей) удалены, итоговый набор равен набору текущей сессии,RemovedFromAccountCountотражает удаление. - Given
strategy = AccountWins, WhenPOST /merge, Then записи сессии удалены, итоговый набор равен прежнему набору аккаунта. - Given пара (
UserId,AnonymousId) уже слита, When повторныйPOST /merge, Then возвращается результат первой операции,WasAlreadyApplied = true, мутаций и события нет (INV-FAV-101). - Given вырожденный случай (сессия пуста / аккаунт пуст / наборы идентичны без конфликтов заметок),
When
POST /mergeс любой присланнойstrategy, Then сервер форсируетMerge,IsAutomatic = true. - Given сессия не связана с актором в
user-activity, WhenPOST /merge, Then отказFAVORITES_SESSION_NOT_LINKED(403). - Given
user-activityнедоступен, WhenPOST /merge, Then отказFAVORITES_MERGE_UNAVAILABLE(503); повтор идемпотентен. - Given две параллельные
mergeодной пары, When гонкаux_merge_ops_user_anonymous, Then одна фиксируется, проигравшая откатывается, её повтор попадает в идемпотентную ветку (WasAlreadyApplied). - Given
strategyвне enumFavoritesMergeStrategy(INV-FAV-103), WhenPOST /merge, Then отказValidationError(400).
Совместимость и наблюдаемость
- Новые значения
FavoritesMergeStrategy— breaking для клиентов с exhaustive-обработкой: вводить через optional-фазу с feature flagfavorites.merge-dialog.enabled. - Logs / metrics (
favorites_merge_total{strategy,automatic}и др.) / traces — раздел «Наблюдаемость» в ../../domain/merge-strategy.md; dashboard/alerts/runbook — ../../README.md.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Merge Strategy: слияние избранного при авторизации (device-wins / account-wins / merge)
- API (2): Favorites API — индекс операций, GET /api/v1/favorites/merge-preview — Предпросмотр слияния избранного
- registries (1): Реестр API-методов PIN
Связанные документы
- Канонический дом: ../../domain/merge-strategy.md (стратегии, таблицы
поведения, правила заметок,
FavoritesMergeOperation, INV-FAV-101..105, полный алгоритм, flowchart). - Событие: ../../events/favorites-merged.md.
- Предпросмотр: get-merge-preview.md; импорт до слияния: import-favorites.md.
- Триггер связывания сессии:
docs/06-services/identity/events/user-signed-in.md(pin.identity.sign-ins.v1) →user-activity(ADR-0054).