Merge Strategy: слияние избранного при авторизации (device-wins / account-wins / merge)
Канонический дом трёх стратегий слияния гостевого и аккаунтного избранного + сущность-аудит
FavoritesMergeOperation. API-метод слияния и use-case ссылаются сюда, не переописывая правила.
Назначение
Пользователь лайкает объекты недвижимости до входа (гостевая сессия, AnonymousId) и мог делать это на разных
устройствах и в разных браузерах (у каждого свой AnonymousId из user-activity), а также имеет
избранное, ранее сохранённое в аккаунте (UserId). После авторизации наборы могут расходиться —
нужно решить, что станет избранным аккаунта. Предусмотрены три стратегии; Merge — рекомендация по
умолчанию.
| Metadata | Значение |
|---|---|
| Owning team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст favorites; операция слияния; таблица favorites.favorites_merge_operations |
| Assumptions | связывание «сессия ↔ пользователь» уже выполнено контекстом user-activity по событию входа pin.identity.sign-ins.v1 (ADR-0054) |
Триггер и точки входа
- Пользователь входит (СМС/пароль) — фронт получает JWT, продолжая слать
X-Anonymous-Idтекущей сессии. - Фронт вызывает
GET /favorites/merge-preview— сервис возвращает счётчики обеих сторон и список конфликтных объектов недвижимости (см. модель ниже). - Ветвление (автоматика без диалога — вырожденные случаи):
| Состояние сторон | Поведение | Диалог пользователю |
|---|---|---|
| Сессия пуста, аккаунт пуст | Ничего не делать (операция не создаётся) | Нет |
| Сессия пуста, аккаунт непуст | Ничего не делать: избранное аккаунта уже активно | Нет |
| Сессия непуста, аккаунт пуст | Автослияние стратегией Merge (вырожденный перенос всех записей сессии) | Нет |
Обе непусты и наборы PropertyObjectId идентичны, заметки не конфликтуют | Автослияние Merge (дедупликация + перенос заметок по правилам) | Нет |
| Обе непусты и различаются | Требуется явный выбор стратегии | Да — 3 варианта |
- Пользователь выбирает стратегию → фронт вызывает
POST /favorites/merge { strategy }. - Первичный импорт (
importFavorites, см. ../api/public/import-favorites.md) выполняется до merge-preview: накопленные на устройствеPropertyObjectIdзаписываются в гостевую сессию (или сразу в аккаунт, если пользователь уже авторизован), после чего работает обычное слияние.
Enum FavoritesMergeStrategy
Хранится как текст (varchar(16)).
| Значение | Смысл |
|---|---|
DeviceWins | «использовать с текущего устройства (станет активным для всех)» — избранное аккаунта заменяется набором текущей сессии |
AccountWins | «использовать активное избранное из аккаунта» — набор текущей сессии отбрасывается |
Merge | «объединить избранные из сессии и текущего пользователя» — объединение наборов с дедупликацией; рекомендация по умолчанию |
Таблица поведения по стратегиям
Обозначения: A = записи аккаунта (UserId = actor), S = записи гостевой сессии (AnonymousId = X-Anonymous-Id),
дубль = объект недвижимости присутствует и в A, и в S.
| Аспект | DeviceWins | AccountWins | Merge (рекомендуется) |
|---|---|---|---|
| Записи A (кроме дублей) | Удаляются физически | Остаются без изменений | Остаются без изменений |
| Записи S (кроме дублей) | Переносятся в аккаунт: UserId := actor, AnonymousId := null, UpdatedAt := now | Удаляются физически | Переносятся в аккаунт (как в DeviceWins) |
| Дубли (объект недвижимости в A и в S) | A-запись удаляется, S-запись переносится (заметка сессии сохраняется) | S-запись удаляется, A-запись остаётся (заметка аккаунта сохраняется) | S-запись удаляется, A-запись остаётся; заметка — по таблице конфликтов заметок ниже |
| Итоговый набор аккаунта | Ровно набор текущего устройства/сессии | Ровно прежний набор аккаунта | Объединение A ∪ S |
| Судьба заметок | Только заметки сессии | Только заметки аккаунта | Обе стороны, конфликт — LastWriterWins |
| Другие устройства/сессии пользователя | После входа на них увидят новый набор аккаунта (их гостевые записи — предмет отдельного слияния при их входе) | Аналогично | Аналогично |
| Потеря данных | Да — прежнее избранное аккаунта | Да — избранное текущей сессии | Нет (кроме проигравшей стороны конфликтной заметки) |
| Когда уместна | Пользователь сознательно «начал заново» на устройстве | Гостевые лайки были случайными | Поведение по умолчанию, «иметь в избранном все объекты» |
Правила слияния заметок (только Merge, для дублей)
| Заметка A | Заметка S | Результат в аккаунтной записи |
|---|---|---|
null | null | null |
null | непустая | Заметка S (переносится, UpdatedAt := now) |
| непустая | null | Заметка A (без изменений) |
| непустая, равна S | непустая | Заметка A (без изменений) |
| непустая, отличается от S | непустая | LastWriterWins: побеждает заметка записи с бо́льшим UpdatedAt; проигравшая теряется (конкатенация запрещена — лимит 4000, INV-FAV-003) |
Рекомендация: Merge по умолчанию
- Единственная стратегия без потери объектов недвижимости; конфликт заметок — редкий кейс (заметки пишут уже вовлечённые пользователи, обычно в одном месте).
- В диалоге фронта
Merge— предвыбранный вариант;DeviceWins/AccountWins— явный выбор с предупреждением о потере данных (счётчики из merge-preview). - Автослияние (вырожденные случаи из таблицы триггеров) всегда выполняется стратегией
Merge.
Entity: FavoritesMergeOperation
Аудит и идемпотентность слияния: одна операция на пару «пользователь + сессия».
Классификация
| Поле | Значение |
|---|---|
| Контекст | favorites |
| Kind | Entity (append-only журнал, не агрегат с поведением) |
| Source of truth | favorites |
| Таблица | favorites.favorites_merge_operations |
| Tenant isolation | не tenanted (ADR-0052 п.2); record-level по UserId |
Запись создаётся один раз и не изменяется; уникальный индекс защищает от гонки параллельных слияний, поэтому версионирование/оптимистическая блокировка не требуются.
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор операции; AggregateId события FavoritesMergedKafkaEvent. |
UserId | Guid | Да | unique ux_merge_ops_user_anonymous (user_id, anonymous_id) — идемпотентность INV-FAV-101; логическая ссылка identity.users(id) без FK | JWT | Пользователь, в чей аккаунт слито избранное. |
AnonymousId | Guid | Да | входит в ux_merge_ops_user_anonymous; логическая ссылка на сессию user-activity без FK | заголовок X-Anonymous-Id | Гостевая сессия-источник. |
Strategy | FavoritesMergeStrategy | Да | хранится как текст, varchar(16) | app (выбор пользователя / автослияние = Merge) | Применённая стратегия. |
IsAutomatic | bool | Да | default false | app | true — вырожденный случай без диалога (таблица триггеров). |
AccountCountBefore | int | Да | >= 0 | вычисление | Записей в аккаунте до слияния. |
SessionCountBefore | int | Да | >= 0 | вычисление | Записей в сессии до слияния. |
MovedCount | int | Да | >= 0 | вычисление | Перенесено S-записей в аккаунт. |
RemovedFromAccountCount | int | Да | >= 0 | вычисление | Удалено A-записей (только DeviceWins). |
RemovedFromSessionCount | int | Да | >= 0 | вычисление | Удалено S-записей (дубли Merge; всё — AccountWins). |
NotesMergedCount | int | Да | >= 0 | вычисление | Заметок перенесено/перезаписано по таблице конфликтов. |
TotalCountAfter | int | Да | >= 0 | вычисление | Итоговое число записей аккаунта. |
CreatedAt | DateTimeOffset | Да | index ix_merge_ops_created_at | app | Момент выполнения. |
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
FavoritesMergeOperation → UserEntity | логическая reference | UserId | не загружается | — |
FavoritesMergeOperation → Session | логическая reference | AnonymousId | не загружается | — |
Инварианты
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-FAV-101 MergeIdempotent | На пару (UserId, AnonymousId) — не более одной операции; повторный вызов возвращает сохранённый результат (идемпотентный успех, без повторных мутаций) | шаг 1 алгоритма: поиск существующей операции по паре; страховка — ux_merge_ops_user_anonymous | — (идемпотентный успех с существующей операцией) |
| INV-FAV-102 SessionLinked | Сессия AnonymousId связана именно с этим UserId в user-activity (нельзя слить чужую сессию, угадав Guid) | шаг 2: internal-запрос user-activity GET /internal/sessions/{anonymousId}/link → linkedUserId == actor.UserId | FAVORITES_SESSION_NOT_LINKED (403) |
| INV-FAV-103 KnownStrategy | strategy ∈ enum FavoritesMergeStrategy | проверка входа | ValidationError |
| INV-FAV-104 SingleTransaction | Все мутации записей + запись операции + публикация события — в одном согласованном изменении; частичное слияние невозможно | фиксация изменения | инфраструктурная ошибка → retry фронтом (идемпотентно по INV-FAV-101) |
| INV-FAV-105 NoCrossUserLeak | Слияние никогда не читает/не трогает записи других UserId/AnonymousId | выборки строго по actor + связанной сессии | — |
Кросс-сервисный контракт с user-activity
Слияние — единственная операция контекста favorites с синхронной зависимостью от соседа: связь
«сессия ↔ пользователь» живёт в user-activity, а не в favorites (нельзя слить чужую сессию, угадав
Guid — INV-FAV-102).
favorites ожидает (вход): синхронный ответ на internal-запрос связи сессии
(GET /internal/sessions/{anonymousId}/link) — идентификатор пользователя, с которым связана сессия
(linkedUserId: Guid?). Связывание выполняет user-activity асинхронно по событию входа
pin.identity.sign-ins.v1 (ADR-0054).
Ответ user-activity | Трактовка favorites | Исход |
|---|---|---|
linkedUserId == actor.UserId | Сессия принадлежит актору | Слияние продолжается |
Сессия не найдена / linkedUserId == null | Связывание ещё не произошло либо сессия чужая | FAVORITES_SESSION_NOT_LINKED (403) |
linkedUserId != actor.UserId | Подставлен чужой AnonymousId | FAVORITES_SESSION_NOT_LINKED (403) + warning (возможный подбор) |
| Timeout / 5xx / недоступен | Ответ не получен | FAVORITES_MERGE_UNAVAILABLE (503); фронт откладывает слияние до следующего открытия /favorites |
Окно eventual-consistency. Связывание сессии в user-activity происходит через секунды после входа
(обработка pin.identity.sign-ins.v1). Если пользователь инициирует слияние раньше, чем user-activity
обработал вход, ответ будет «не связана» → FAVORITES_SESSION_NOT_LINKED; фронт повторяет при следующем
открытии /favorites (идемпотентно по INV-FAV-101). В промежуточном состоянии избранное аккаунта и
гостевой сессии видны раздельно (каждое по своему фильтру), потери данных нет.
favorites предоставляет (выход): по завершении — FavoritesMergedKafkaEvent
(../events/favorites-merged.md; ключ партиционирования UserId,
ContractVersion = 1, AggregateId = MergeOperationId); потребитель user-activity пишет ActionEvent
«объединил избранное». Событие асинхронно: итоговый список аккаунта доступен сразу (GET /favorites — то
же хранилище, сильная согласованность), запись в истории действий появляется < 1 мин.
Модель ответа FavoritesMergePreviewResult (GET /favorites/merge-preview)
| Поле | Тип | Обязательность | Описание |
|---|---|---|---|
AccountCount | int | Да | Записей в аккаунте. |
SessionCount | int | Да | Записей в гостевой сессии. |
DuplicatePropertyObjectIds | IReadOnlyList<Guid> | Да (может быть пустым) | Объекты недвижимости, присутствующие в обеих сторонах. |
NoteConflictPropertyObjectIds | IReadOnlyList<Guid> | Да (может быть пустым) | Дубли, у которых обе заметки непусты и различаются (пользователю можно показать предупреждение). |
RequiresChoice | bool | Да | true — нужен диалог 3 вариантов; false — было/будет автослияние. |
AppliedOperationId | Guid? | Нет | Если пара уже слита (INV-FAV-101) — id существующей операции. |
Алгоритм слияния
- Контекст и доступ. Актор — авторизованный пользователь (
UserIdиз JWT claimsub); гостевая сессия —AnonymousIdиз заголовкаX-Anonymous-Id. Проверка идемпотентности: если по паре (UserId,AnonymousId) операция слияния уже существует — вернуть её сохранённый результат без каких-либо мутаций (INV-FAV-101). - Проверка входа и предусловий.
strategy∈ enumFavoritesMergeStrategy(INV-FAV-103), иначеValidationError;AnonymousId != Guid.Empty. Связь сессии: синхронный internal-запрос вuser-activityподтверждает, что сессия существует и связана именно с этим пользователем (INV-FAV-102). Исходы: сессия не найдена или связана с другимUserId→FAVORITES_SESSION_NOT_LINKED(403);user-activityнедоступен (timeout/5xx) →FAVORITES_MERGE_UNAVAILABLE(503) — слияние откладывается, повтор безопасен (идемпотентность INV-FAV-101); контракт связи и трактовка ответов — раздел «Кросс-сервисный контракт сuser-activity» ниже. Существование объектов недвижимости не проверяется (INV-FAV-004, favorite.md). - Что читаем. Обе стороны читаются под блокировкой (чтобы параллельное добавление не потерялось между
чтением и мутацией): записи сессии (
AnonymousId = actor session) и записи аккаунта (UserId = actor). Строится множество дублей — объектов недвижимости, присутствующих и в аккаунте, и в сессии. - Бизнес-правила и маппинг. По выбранной
strategy(таблица поведения выше) вычисляются три множества:toDelete(записи целиком),toMove(S-записи:UserId := actor,AnonymousId := null,UpdatedAt := now),noteUpdates(дляMerge-дублей — целевая заметка A-записи по таблице конфликтов заметок, сравнениеUpdatedAtсторон — LastWriterWins). Счётчики полейFavoritesMergeOperationсчитаются из мощности множеств (TotalCountAfter = |A| - removedFromAccount + moved). - Что меняется. В одном согласованном изменении, в порядке из-за partial-unique
ux_favorites_user_property_object: (1) удаляются проигравшие записиtoDelete(A приDeviceWins; S-дубли приMerge; все S приAccountWins) — освобождают пары (user_id,property_object_id); (2) переносятся S-записиtoMove(смена владельца); (3) обновляются заметки поnoteUpdates; (4) создаётся записьFavoritesMergeOperation(аудит + идемпотентность). - Какие факты/события фиксируются. Публикуется
FavoritesMergedKafkaEvent(ContractVersion = 1, topicpin.favorites.favorites-merged.v1,AggregateId = operation.Id,AggregateType = "FavoritesMergeOperation"); payload — все поляFavoritesMergeOperation, кроме заметок. Потребитель —user-activity(ActionEvent «слил избранное» в историю действий). - Согласованность. Мутации записей, запись операции и публикация события фиксируются в одном согласованном изменении (INV-FAV-104). Конфликт unique-индекса операции (гонка двух параллельных merge) откатывает проигравшего; его повторный вызов попадает в идемпотентную ветку шага 1.
- Результат. Возвращаются
OperationId,Strategy,IsAutomatic,MovedCount,RemovedFromAccountCount,RemovedFromSessionCount,NotesMergedCount,TotalCountAfter(маппинг 1:1 из полей созданной операции); заметки в ответ не включаются.
Ошибки операции слияния
Консолидированный контракт кодов (дублируется в шагах алгоритма и таблице инвариантов).
| Код | HTTP | Условие | Retry |
|---|---|---|---|
ValidationError | 400 | strategy вне enum FavoritesMergeStrategy (INV-FAV-103); AnonymousId == Guid.Empty | no retry (дефект вызова) |
FAVORITES_SESSION_NOT_LINKED | 403 | Сессия не найдена или связана с другим UserId (INV-FAV-102) | не ретраить автоматически; возможна отложенная попытка после связывания сессии |
FAVORITES_MERGE_UNAVAILABLE | 503 | user-activity недоступен при проверке связи (timeout/5xx) | retry позже (идемпотентно по INV-FAV-101) |
CONCURRENCY (unique violation → откат) | 409/500 | Гонка двух параллельных merge одной пары — проигравший полностью откатывается (INV-FAV-104) | retry → попадает в идемпотентную ветку (сохранённый результат) |
Идемпотентный повтор уже выполненной пары (UserId, AnonymousId) — успех с сохранённым результатом,
не ошибка (INV-FAV-101); события при повторе не публикуются.
Наблюдаемость
- Logs: старт/финиш merge c
OperationId,Strategy, счётчиками (безPropertyObjectId-списков и заметок); warning приFAVORITES_SESSION_NOT_LINKED(возможный подбор чужогоAnonymousId). - Metrics:
favorites_merge_total{strategy,automatic},favorites_merge_moved_count(histogram),favorites_merge_conflicts_total(конфликты заметок). - Traces: сквозной correlation, включая internal-вызов
user-activity. - Dashboard/alerts/runbook — см. ../README.md (Runtime И Эксплуатация).
Индексы, хранение, безопасность (favorites_merge_operations)
| Аспект | Значение |
|---|---|
| Индексы | PK btree(id); ux_merge_ops_user_anonymous unique btree(user_id, anonymous_id); ix_merge_ops_created_at btree(created_at) |
| Партиционирование / retention | Нет; журнал мал (≤ 1 операция на пару «пользователь×устройство»), не чистится |
| Concurrency | Уникальный индекс защищает от гонки параллельных merge |
| Permissions / PII | Запись видит только сам пользователь (record-level UserId) и служба заботы (permission favorites.merge-log.view, расследования); PII нет — только id и счётчики |
| Audit | Сама сущность и есть аудит; плюс FavoritesMergedKafkaEvent |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: Favorite
- API (6): Favorites API — индекс операций, GET /api/v1/favorites/merge-preview — Предпросмотр слияния избранного, POST /api/v1/favorites/import — Bulk-импорт избранного с устройства, POST /api/v1/favorites/merge — Применить стратегию слияния избранного, POST /api/v1/selections/from-favorites — Создать подборку из текущего избранного, PUT /api/v1/favorites/{propertyObjectId}/note — Установить/очистить заметку к объекту недвижимости
- Use Cases (4): UC-FAV-001. Гость добавляет объект недвижимости в избранное (гостевая сессия), UC-FAV-002. Авторизация → слияние гостевого и аккаунтного избранного (3 стратегии), UC-FAV-003. Заметка к объекту недвижимости в избранном (установка/правка/очистка), UC-FAV-004. Первичная миграция избранного с устройства (localStorage → бэкенд)
- События (1): Event Contract: pin.favorites.favorites-merged (FavoritesMergedKafkaEvent)
- Консюмеры (1): Consumers контекста favorites: решение «consumed-событий нет»
- index.md (1): Favorites Service (Pin.Favorites)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- favorite.md — дом полей
Favorite(INV-FAV-001..006). - ../README.md — карта API (
merge-preview,merge,import), границы. - Применение стратегии: ../api/public/merge-favorites.md; предпросмотр: ../api/public/get-merge-preview.md.
- Событие: ../events/favorites-merged.md.
- Решения: ADR-0052, ADR-0054, ADR-0056.
- Будущие пути:
docs/06-services/user-activity/README.md(internal API связи сессии).