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

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 areapublic
Feature groupfavorites-merge
Статусdraft
Документdocs/06-services/favorites/api/public/merge-favorites.md

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

ПроверкаЗначение
Actor typeUser (JWT обязателен — сливать можно только в собственный аккаунт)
Auth policy / Permissionsauthenticated; отдельных 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)
Auditappend-only FavoritesMergeOperation + FavoritesMergedKafkaEvent; журнал слияний доступен службе заботы по permission favorites.merge-log.view

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/favorites/merge
Success status200 (в т.ч. идемпотентный повтор — возвращается результат существующей операции)
Idempotency headerне применяется — идемпотентность по бизнес-ключу (UserId, AnonymousId), INV-FAV-101 + unique ux_merge_ops_user_anonymous
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity (JWT claim sub)GuidДаJWT обязателенприёмник слияния — только сам актор
AnonymousIdheader X-Anonymous-IdGuidДа!= Guid.Empty; связь с актором проверяется в user-activity (INV-FAV-102)недоверенный вход — подбор чужого Guid отбивается 403
Strategybody strategyFavoritesMergeStrategyДа∈ {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Описание
OperationIdGuidДаFavoritesMergeOperation.IdИдентификатор операции слияния.
StrategyFavoritesMergeStrategyДаFavoritesMergeOperation.StrategyФактически применённая стратегия.
IsAutomaticboolДаFavoritesMergeOperation.IsAutomaticСлияние без диалога (вырожденный случай).
MovedCountintДаFavoritesMergeOperation.MovedCountПеренесено записей сессии в аккаунт.
RemovedFromAccountCountintДаFavoritesMergeOperation.RemovedFromAccountCountУдалено записей аккаунта (только DeviceWins).
RemovedFromSessionCountintДаFavoritesMergeOperation.RemovedFromSessionCountУдалено записей сессии (дубли Merge; всё — AccountWins).
NotesMergedCountintДаFavoritesMergeOperation.NotesMergedCountЗаметок перенесено/перезаписано (правила LastWriterWins).
TotalCountAfterintДаFavoritesMergeOperation.TotalCountAfterИтоговый размер избранного аккаунта.
WasAlreadyAppliedboolДаcomputed: попадание в идемпотентную веткуtrue — вернулся результат ранее выполненной операции (INV-FAV-101).

Тексты заметок и списки PropertyObjectId в ответ не включаются (заметки приватны; итоговый список фронт читает отдельным GET /favorites).

Алгоритм

Канонический алгоритм слияния — раздел «Алгоритм слияния» в ../../domain/merge-strategy.md; ниже — те же шаги в привязке к HTTP-обвязке (дельта: определение IsAutomatic, HTTP-статусы):

  1. Контекст и доступ. Актор = UserId из JWT; гостевая сессия — AnonymousId из заголовка. Если по паре (UserId, AnonymousId) операция слияния уже существует → вернуть её результат (WasAlreadyApplied = true), HTTP 200, без мутаций (INV-FAV-101).
  2. Проверка входа. Strategy ∈ enum (INV-FAV-103), иначе ValidationError; AnonymousId != Guid.Empty. Internal-запрос user-activity GET /internal/sessions/{anonymousId}/link подтверждает связь сессии с актором (INV-FAV-102), иначе отказ FAVORITES_SESSION_NOT_LINKED; недоступность — отказ FAVORITES_MERGE_UNAVAILABLE. Объекты недвижимости не проверяются (INV-FAV-004).
  3. Что читаем. Обе стороны читаются под блокировкой (параллельный add не теряется): записи сессии (AnonymousId = actor session) и записи аккаунта (UserId = actor); строится множество дублей по PropertyObjectId.
  4. Бизнес-правила и маппинг. Определить 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).
  5. Что меняется. В порядке из-за partial-unique ux_favorites_user_property_object: (1) удаляются toDelete; (2) переносятся toMove (UserId := actor, AnonymousId := null, UpdatedAt := now); (3) обновляются заметки по noteUpdates; (4) создаётся запись FavoritesMergeOperation.
  6. Какие факты/события фиксируются. Публикуется FavoritesMergedKafkaEvent (ContractVersion = 1, topic pin.favorites.favorites-merged.v1, AggregateId = operation.Id, AggregateType = "FavoritesMergeOperation"; payload — поля операции, без заметок). Потребитель — user-activity (ActionEvent «слил избранное»). Отдельные события add/remove на каждую перенесённую запись не публикуются — факт слияния атомарный.
  7. Согласованность. Мутации записей, запись операции и публикация события фиксируются в одном согласованном изменении (INV-FAV-104). Гонка параллельных merge → unique ux_merge_ops_user_anonymous откатывает проигравшего; его повтор попадает в идемпотентную ветку шага 1.
  8. Результат. Модель FavoritesMergeResult (маппинг из операции, WasAlreadyApplied = false), HTTP 200 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
Unauthorized401Нет/невалиден JWTстандартный ответ gatewayпосле входа
ValidationError400strategy вне enum (INV-FAV-103); X-Anonymous-Id отсутствует/Guid.Emptyдетали валидацииno retry
FAVORITES_SESSION_NOT_LINKED403Сессия не связана с актором (INV-FAV-102)«Сессия не принадлежит пользователю»no retry; warning в лог (алерт на всплеск — README)
FAVORITES_MERGE_UNAVAILABLE503user-activity недоступен«Слияние временно недоступно»retry с backoff — идемпотентно
InternalError500Отказ хранилища; откат по unique-гонке, исчерпан retrygeneric messageretry — попадёт в идемпотентную ветку

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, When POST /merge, Then записи сессии перенесены во владельца UserId, дубли дедуплицированы, заметки — по LastWriterWins, создана FavoritesMergeOperation, опубликован FavoritesMergedKafkaEvent (key = UserId), ответ 200, WasAlreadyApplied = false.
  • Given strategy = DeviceWins, When POST /merge, Then записи аккаунта (кроме дублей) удалены, итоговый набор равен набору текущей сессии, RemovedFromAccountCount отражает удаление.
  • Given strategy = AccountWins, When POST /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, When POST /merge, Then отказ FAVORITES_SESSION_NOT_LINKED (403).
  • Given user-activity недоступен, When POST /merge, Then отказ FAVORITES_MERGE_UNAVAILABLE (503); повтор идемпотентен.
  • Given две параллельные merge одной пары, When гонка ux_merge_ops_user_anonymous, Then одна фиксируется, проигравшая откатывается, её повтор попадает в идемпотентную ветку (WasAlreadyApplied).
  • Given strategy вне enum FavoritesMergeStrategy (INV-FAV-103), When POST /merge, Then отказ ValidationError (400).

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

  • Новые значения FavoritesMergeStrategy — breaking для клиентов с exhaustive-обработкой: вводить через optional-фазу с feature flag favorites.merge-dialog.enabled.
  • Logs / metrics (favorites_merge_total{strategy,automatic} и др.) / traces — раздел «Наблюдаемость» в ../../domain/merge-strategy.md; dashboard/alerts/runbook — ../../README.md.

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

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

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

  • Канонический дом: ../../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).