UC-FAV-002. Авторизация → слияние гостевого и аккаунтного избранного (3 стратегии)
Назначение
Описывает сценарий слияния избранного после авторизации: гость лайкал объекты недвижимости в текущей
сессии (AnonymousId), а в аккаунте (UserId) уже могло быть избранное с других устройств/сессий —
наборы расходятся. Пользователю предлагаются три стратегии: DeviceWins («использовать с текущего
устройства — станет активным для всех»), AccountWins («использовать активное избранное из аккаунта»),
Merge («объединить» — рекомендация по умолчанию). Вырожденные случаи сливаются автоматически без
диалога. Канонический дом правил и алгоритма — ../domain/merge-strategy.md;
этот use case описывает пользовательский поток и дельту. Связан с контекстом favorites.
| Поле | Значение |
|---|
| ID | UC-FAV-002 |
| Контекст | favorites (см. docs/06-services/favorites/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | Инвестор (либо Агент — любой только что авторизовавшийся пользователь) |
| Permissions / scope | self-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 при заходе с активной гостевой сессией |
Основной Поток
- Интерфейс после входа вызывает
GET /favorites/merge-preview (JWT + X-Anonymous-Id).
- Система читает записи по
UserId = actor и по AnonymousId = заголовок и вычисляет предпросмотр
FavoritesMergePreviewResult — AccountCount, SessionCount, DuplicatePropertyObjectIds,
NoteConflictPropertyObjectIds, RequiresChoice, AppliedOperationId? (модель — дом
../domain/merge-strategy.md). Это чтение — изменений и событий нет.
- Интерфейс ветвится по таблице триггеров (дом — merge-strategy.md «Триггер и точки входа»):
- обе стороны пусты, либо сессия пуста — ничего не делает (диалога нет, операция не создаётся);
- аккаунт пуст или наборы идентичны без конфликтов заметок — автослияние: интерфейс сразу вызывает
POST /favorites/merge { strategy: "Merge" } (операция помечается IsAutomatic = true);
- обе стороны непусты и различаются (
RequiresChoice = true) — показывает диалог трёх вариантов
со счётчиками из preview: «Использовать с этого устройства (N объектов недвижимости; M из аккаунта будут удалены)» /
«Оставить избранное аккаунта (K объектов недвижимости сессии будут удалены)» / «Объединить (рекомендуется, без
потерь)». Предвыбран Merge; деструктивные варианты — с явным предупреждением о потере данных.
- Пользователь подтверждает выбор → интерфейс вызывает
POST /favorites/merge с телом
MergeFavoritesForm { strategy: FavoritesMergeStrategy ("DeviceWins" | "AccountWins" | "Merge") }.
- Система выполняет слияние (полный алгоритм — в доме ../domain/merge-strategy.md
«Алгоритм слияния»); сводка:
- определяются actor из JWT и
AnonymousId из заголовка; если для этой пары уже есть выполненная
операция слияния — она возвращается без изменений (идемпотентность INV-FAV-101);
strategy должна входить в enum (INV-FAV-103); связь сессии с актором проверяется во внешнем
контексте user-activity (GET /internal/sessions/{anonymousId}/link → linkedUserId == actor) —
INV-FAV-102, иначе FAVORITES_SESSION_NOT_LINKED;
- загружаются обе стороны избранного (аккаунт по
UserId, сессия по AnonymousId) под конкурентной
блокировкой;
- вычисляются множества
toDelete / toMove / noteUpdates по таблице поведения стратегий и таблице
конфликтов заметок (LastWriterWins по UpdatedAt);
- изменения применяются в порядке: удаление → перенос владельца → обновление заметок → создание
записи операции;
- факт слияния публикуется событием
FavoritesMergedKafkaEvent в топик
pin.favorites.favorites-merged.v1;
- все изменения и запись операции применяются в одном согласованном изменении (INV-FAV-104);
- возвращается
FavoritesMergeResult со счётчиками операции.
- Интерфейс показывает итог («Объединено: N объектов недвижимости»), обновляет список и запрашивает
GET /favorites — итоговый список аккаунта.
- Consumer
user-activity получает favorites-merged → ActionEvent «слил избранное» в истории действий.
Альтернативные Потоки
A1. Стратегия DeviceWins — «использовать с текущего устройства»
- Пользователь сознательно выбирает набор текущего устройства/сессии.
- Все A-записи аккаунта (кроме дублей) физически удаляются; S-записи переносятся в аккаунт
(
UserId := actor, AnonymousId := null); у дублей побеждает запись сессии с её заметкой
(таблица поведения — merge-strategy.md).
- Итоговый набор аккаунта = ровно набор сессии; «станет активным для всех» — другие устройства
после входа увидят этот набор (их собственные гостевые записи — предмет их слияния при их входе).
Возврат к основному потоку, шаг 6.
A2. Стратегия AccountWins — «использовать активное избранное из аккаунта»
- Гостевые лайки были случайными: все S-записи сессии физически удаляются, аккаунт не меняется
(
MovedCount = 0, RemovedFromSessionCount = |S|).
- Возврат к основному потоку, шаг 6.
A3. Стратегия Merge (рекомендуемая, в т.ч. автослияние)
- Объединение A ∪ S: S-записи без дубля переносятся в аккаунт; у дублей остаётся A-запись, S-дубль
удаляется; конфликтные заметки — LastWriterWins по
UpdatedAt (таблица конфликтов заметок —
merge-strategy.md). Объекты недвижимости не теряются.
- При автослиянии (шаг 3 основного потока) операция помечается
IsAutomatic = true.
Возврат к основному потоку, шаг 6.
A4. Пара уже слита (повторный вызов, INV-FAV-101)
- Для пары (
UserId, AnonymousId) уже есть выполненная операция (retry интерфейса, двойной клик,
повторный вход в той же сессии) → возвращается сохранённый FavoritesMergeResult; изменений и
повторного события нет.
- Preview в этом случае возвращает
AppliedOperationId и RequiresChoice = false — диалог не
показывается. Завершается.
A5. Сессия не связана с пользователем (INV-FAV-102)
user-activity вернул linkedUserId != actor.UserId (или сессия не найдена) →
FAVORITES_SESSION_NOT_LINKED (403); warning в лог (возможный подбор чужого AnonymousId;
alert при всплеске — ../README.md «Alerts»).
- Интерфейс не ретраит автоматически; гостевые записи остаются гостевыми. Завершается.
A6. user-activity недоступен
- Запрос связи сессии упал (timeout/5xx) →
FAVORITES_MERGE_UNAVAILABLE (503).
- Интерфейс откладывает слияние и повторяет при следующем открытии
/favorites (операция идемпотентна —
A4 защищает от дублей). Обычные add/remove/список работают. Завершается.
A7. Гонка двух параллельных merge (две вкладки)
- Обе операции прошли проверку идемпотентности до вставки; вторая наталкивается на уникальный индекс
ux_merge_ops_user_anonymous → её изменения полностью откатываются (INV-FAV-104).
- Повторный вызов (retry интерфейса) попадает в идемпотентную ветку A4. Завершается.
A8. Вход на втором устройстве позже
- У второго устройства свой
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 | Условие | Поведение клиента |
|---|
ValidationError | 400 | strategy вне enum (INV-FAV-103); X-Anonymous-Id пуст | Исправить запрос (дефект фронта) |
FAVORITES_SESSION_NOT_LINKED | 403 | Сессия не связана с этим пользователем (INV-FAV-102, A5) | Не ретраить; продолжить без слияния |
FAVORITES_MERGE_UNAVAILABLE | 503 | user-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
| # | Критерий | Проверка |
|---|
| AC1 | Merge: итог = A ∪ S без дублей; ни один объект недвижимости не потерян | интеграционный тест пересекающихся наборов |
| AC2 | DeviceWins: итог = ровно S; прежние A-записи удалены; заметки дублей — от сессии | интеграционный тест |
| AC3 | AccountWins: итог = ровно 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 + MovedCount | property-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.