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

UC-FAV-002. Авторизация → слияние гостевого и аккаунтного избранного (3 стратегии)

Назначение

Описывает сценарий слияния избранного после авторизации: гость лайкал объекты недвижимости в текущей сессии (AnonymousId), а в аккаунте (UserId) уже могло быть избранное с других устройств/сессий — наборы расходятся. Пользователю предлагаются три стратегии: DeviceWins («использовать с текущего устройства — станет активным для всех»), AccountWins («использовать активное избранное из аккаунта»), Merge («объединить» — рекомендация по умолчанию). Вырожденные случаи сливаются автоматически без диалога. Канонический дом правил и алгоритма — ../domain/merge-strategy.md; этот use case описывает пользовательский поток и дельту. Связан с контекстом favorites.

Metadata

ПолеЗначение
IDUC-FAV-002
Контекстfavorites (см. docs/06-services/favorites/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольИнвестор (либо Агент — любой только что авторизовавшийся пользователь)
Permissions / scopeself-scope: JWT (UserId из claim sub) + заголовок X-Anonymous-Id текущей сессии; отдельных permissions нет
Tenantне tenanted (ADR-0052 п.2)
Record-levelслияние читает/мутирует только записи UserId = actor и AnonymousId = заголовок (INV-FAV-105); связь сессии с актором проверяется INV-FAV-102

Предусловие

  • Пользователь только что вошёл (СМС/пароль, identity), интерфейс имеет JWT и продолжает слать X-Anonymous-Id той же гостевой сессии.
  • user-activity связал сессию с пользователем по событию входа pin.identity.sign-ins.v1 (ADR-0054; assumption — ../domain/merge-strategy.md Metadata).
  • Если на устройстве оставался localStorage pin:favorites — первичный импорт (UC-FAV-004) выполнен до merge-preview.

Триггер

ПолеЗначение
ИсточникAPI call (интерфейс после успешного входа)
Триггерящий элементAuth-модалка → колбэк успешной авторизации; страница /favorites при заходе с активной гостевой сессией

Основной Поток

  1. Интерфейс после входа вызывает GET /favorites/merge-preview (JWT + X-Anonymous-Id).
  2. Система читает записи по UserId = actor и по AnonymousId = заголовок и вычисляет предпросмотр FavoritesMergePreviewResultAccountCount, SessionCount, DuplicatePropertyObjectIds, NoteConflictPropertyObjectIds, RequiresChoice, AppliedOperationId? (модель — дом ../domain/merge-strategy.md). Это чтение — изменений и событий нет.
  3. Интерфейс ветвится по таблице триггеров (дом — merge-strategy.md «Триггер и точки входа»):
    • обе стороны пусты, либо сессия пуста — ничего не делает (диалога нет, операция не создаётся);
    • аккаунт пуст или наборы идентичны без конфликтов заметок — автослияние: интерфейс сразу вызывает POST /favorites/merge { strategy: "Merge" } (операция помечается IsAutomatic = true);
    • обе стороны непусты и различаются (RequiresChoice = true) — показывает диалог трёх вариантов со счётчиками из preview: «Использовать с этого устройства (N объектов недвижимости; M из аккаунта будут удалены)» / «Оставить избранное аккаунта (K объектов недвижимости сессии будут удалены)» / «Объединить (рекомендуется, без потерь)». Предвыбран Merge; деструктивные варианты — с явным предупреждением о потере данных.
  4. Пользователь подтверждает выбор → интерфейс вызывает POST /favorites/merge с телом MergeFavoritesForm { strategy: FavoritesMergeStrategy ("DeviceWins" | "AccountWins" | "Merge") }.
  5. Система выполняет слияние (полный алгоритм — в доме ../domain/merge-strategy.md «Алгоритм слияния»); сводка:
    1. определяются actor из JWT и AnonymousId из заголовка; если для этой пары уже есть выполненная операция слияния — она возвращается без изменений (идемпотентность INV-FAV-101);
    2. strategy должна входить в enum (INV-FAV-103); связь сессии с актором проверяется во внешнем контексте user-activity (GET /internal/sessions/{anonymousId}/linklinkedUserId == actor) — INV-FAV-102, иначе FAVORITES_SESSION_NOT_LINKED;
    3. загружаются обе стороны избранного (аккаунт по UserId, сессия по AnonymousId) под конкурентной блокировкой;
    4. вычисляются множества toDelete / toMove / noteUpdates по таблице поведения стратегий и таблице конфликтов заметок (LastWriterWins по UpdatedAt);
    5. изменения применяются в порядке: удаление → перенос владельца → обновление заметок → создание записи операции;
    6. факт слияния публикуется событием FavoritesMergedKafkaEvent в топик pin.favorites.favorites-merged.v1;
    7. все изменения и запись операции применяются в одном согласованном изменении (INV-FAV-104);
    8. возвращается FavoritesMergeResult со счётчиками операции.
  6. Интерфейс показывает итог («Объединено: N объектов недвижимости»), обновляет список и запрашивает GET /favorites — итоговый список аккаунта.
  7. Consumer user-activity получает favorites-merged → ActionEvent «слил избранное» в истории действий.

Альтернативные Потоки

A1. Стратегия DeviceWins — «использовать с текущего устройства»

  1. Пользователь сознательно выбирает набор текущего устройства/сессии.
  2. Все A-записи аккаунта (кроме дублей) физически удаляются; S-записи переносятся в аккаунт (UserId := actor, AnonymousId := null); у дублей побеждает запись сессии с её заметкой (таблица поведения — merge-strategy.md).
  3. Итоговый набор аккаунта = ровно набор сессии; «станет активным для всех» — другие устройства после входа увидят этот набор (их собственные гостевые записи — предмет их слияния при их входе). Возврат к основному потоку, шаг 6.

A2. Стратегия AccountWins — «использовать активное избранное из аккаунта»

  1. Гостевые лайки были случайными: все S-записи сессии физически удаляются, аккаунт не меняется (MovedCount = 0, RemovedFromSessionCount = |S|).
  2. Возврат к основному потоку, шаг 6.

A3. Стратегия Merge (рекомендуемая, в т.ч. автослияние)

  1. Объединение A ∪ S: S-записи без дубля переносятся в аккаунт; у дублей остаётся A-запись, S-дубль удаляется; конфликтные заметки — LastWriterWins по UpdatedAt (таблица конфликтов заметок — merge-strategy.md). Объекты недвижимости не теряются.
  2. При автослиянии (шаг 3 основного потока) операция помечается IsAutomatic = true. Возврат к основному потоку, шаг 6.

A4. Пара уже слита (повторный вызов, INV-FAV-101)

  1. Для пары (UserId, AnonymousId) уже есть выполненная операция (retry интерфейса, двойной клик, повторный вход в той же сессии) → возвращается сохранённый FavoritesMergeResult; изменений и повторного события нет.
  2. Preview в этом случае возвращает AppliedOperationId и RequiresChoice = false — диалог не показывается. Завершается.

A5. Сессия не связана с пользователем (INV-FAV-102)

  1. user-activity вернул linkedUserId != actor.UserId (или сессия не найдена) → FAVORITES_SESSION_NOT_LINKED (403); warning в лог (возможный подбор чужого AnonymousId; alert при всплеске — ../README.md «Alerts»).
  2. Интерфейс не ретраит автоматически; гостевые записи остаются гостевыми. Завершается.

A6. user-activity недоступен

  1. Запрос связи сессии упал (timeout/5xx) → FAVORITES_MERGE_UNAVAILABLE (503).
  2. Интерфейс откладывает слияние и повторяет при следующем открытии /favorites (операция идемпотентна — A4 защищает от дублей). Обычные add/remove/список работают. Завершается.

A7. Гонка двух параллельных merge (две вкладки)

  1. Обе операции прошли проверку идемпотентности до вставки; вторая наталкивается на уникальный индекс ux_merge_ops_user_anonymous → её изменения полностью откатываются (INV-FAV-104).
  2. Повторный вызов (retry интерфейса) попадает в идемпотентную ветку A4. Завершается.

A8. Вход на втором устройстве позже

  1. У второго устройства свой AnonymousId — при входе там выполняется своя пара preview → merge (новая запись FavoritesMergeOperation); стратегия выбирается заново. Слияние per-пара «пользователь × сессия», не глобальное. Завершается.

Постусловие

  • Избранное аккаунта соответствует выбранной стратегии (таблица поведения — merge-strategy.md); гостевых записей с данным AnonymousId не осталось (перенесены или удалены) — кроме AccountWins, где они удалены все.
  • Создана ровно одна запись FavoritesMergeOperation на пару (UserId, AnonymousId) со стратегией, флагом IsAutomatic и счётчиками (AccountCountBefore, SessionCountBefore, MovedCount, RemovedFromAccountCount, RemovedFromSessionCount, NotesMergedCount, TotalCountAfter).
  • Опубликовано pin.favorites.favorites-merged.v1. Итоговый набор аккаунта доступен сразу (GET /favorites — то же хранилище, сильная согласованность); ActionEvent «слил избранное» в истории user-activity появляется асинхронно (< 1 мин, at-least-once). В промежутке пользователь уже видит корректный список, запись в ленте действий — с задержкой.
  • Undo не предусмотрен (../README.md «Известные Ограничения»); откат — только восстановление из бэкапа по runbook.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
ValidationError400strategy вне enum (INV-FAV-103); X-Anonymous-Id пустИсправить запрос (дефект фронта)
FAVORITES_SESSION_NOT_LINKED403Сессия не связана с этим пользователем (INV-FAV-102, A5)Не ретраить; продолжить без слияния
FAVORITES_MERGE_UNAVAILABLE503user-activity недоступен (A6)Отложить, повторить позже (идемпотентно)
5xx (unique violation после отката)500Гонка параллельных merge (A7)Повторить запрос → идемпотентный успех

Доменные События

СобытиеКогда поднимаетсяSubscribers
FavoriteNoteUpdatedEventЗаметка A-записи перезаписана правилом LastWriterWins (только Merge)Внутренние (метрика favorites_merge_conflicts_total)

Отдельные FavoriteAddedEvent/FavoriteRemovedEvent при слиянии не поднимаются — перенос/удаление в рамках merge не является пользовательским add/remove; факт слияния передаётся одним событием ниже.

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
FavoritesMergedKafkaEvent (ContractVersion = 1, AggregateId = MergeOperationId)pin.favorites.favorites-merged.v1Успешное завершение операции (не при идемпотентном повторе A4)user-activity (ActionEvent «слил избранное»)

Payload — поля FavoritesMergeOperation (стратегия, IsAutomatic, счётчики), без списков PropertyObjectId и без заметок.

Acceptance Criteria

#КритерийПроверка
AC1Merge: итог = A ∪ S без дублей; ни один объект недвижимости не потерянинтеграционный тест пересекающихся наборов
AC2DeviceWins: итог = ровно S; прежние A-записи удалены; заметки дублей — от сессииинтеграционный тест
AC3AccountWins: итог = ровно A; S-записи удаленыинтеграционный тест
AC4Конфликт заметок при Merge решается по большему UpdatedAt (LastWriterWins)параметризованный тест таблицы конфликтов заметок
AC5Вырожденные случаи (сессия пуста / аккаунт пуст / наборы идентичны) — без диалога; автослияние помечено IsAutomatic = trueтесты таблицы триггеров
AC6Повторный merge той же пары — идемпотентный успех, без мутаций и второго события (A4)тест идемпотентности + outbox
AC7Чужой AnonymousId (не связанный с актором) → 403 FAVORITES_SESSION_NOT_LINKED (A5)негативный тест
AC8Все мутации + операция + факт слияния — одно согласованное изменение: искусственный сбой на шаге мутаций не оставляет частичного слияния (INV-FAV-104)fault-injection тест
AC9Счётчики операции сходятся: TotalCountAfter = AccountCountBefore - RemovedFromAccountCount + MovedCountproperty-based тест

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

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

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

  • Дом алгоритма и правил: ../domain/merge-strategy.md (enum, таблицы поведения/заметок/триггеров, FavoritesMergeOperation, INV-FAV-101..105, полный алгоритм).
  • API методы: ../api/public/get-merge-preview.md, ../api/public/merge-favorites.md.
  • Domain entity: ../domain/favorite.md (INV-FAV-006 — смена владельца только merge/import).
  • Предшествующий шаг на устройстве с localStorage: UC-FAV-004.
  • Решение о трёх стратегиях: technical-assignment.md; ADR-0054 (связывание сессии), ADR-0056; событие входа — docs/06-services/identity/events/user-signed-in.md.