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

UC-FAV-004. Первичная миграция избранного с устройства (localStorage → бэкенд)

Назначение

Описывает одноразовый bulk-импорт избранного с устройства: интерфейс может хранить лайки гостя в localStorage браузера (ключ pin:favorites) до перехода на бэкенд-избранное. При первой загрузке страницы /favorites он переносит содержимое pin:favorites в текущего владельца (гостевую сессию или аккаунт) вызовом POST /favorites/import, после успеха очищает ключ и дальше работает только через API. Импорт идемпотентен — существующие пары пропускаются. Связан с контекстом favorites.

Metadata

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

Актор

ПолеЗначение
РольГость (X-Anonymous-Id) или Инвестор/Агент (JWT) — кто открыл /favorites первым
Permissions / scopeself-scope: импорт всегда в собственного владельца, чужой target недоступен
Tenantне tenanted (ADR-0052 п.2)
Record-levelсоздаваемые записи получают владельца актора (UserId из JWT либо AnonymousId из заголовка) — INV-FAV-001/005

Предусловие

  • В localStorage устройства есть ключ pin:favorites (массив id объектов недвижимости, накопленный локально).
  • У гостя есть AnonymousId от user-activity (либо пользователь авторизован — тогда владелец берётся из JWT).
  • Импорт ещё не выполнялся на этом устройстве (ключ не очищен) — маркер и есть сам ключ localStorage.

Триггер

ПолеЗначение
ИсточникАвтоматический API call интерфейса (без действия пользователя)
Триггерящий элементПервая загрузка приложения/страницы /favorites при непустом pin:favorites

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

  1. Интерфейс при инициализации обнаруживает непустой pin:favorites, парсит его в массив propertyObjectIds (невалидные значения — не-Guid — отбрасывает на клиенте).
  2. Интерфейс отправляет POST /favorites/import с телом ImportFavoritesForm { propertyObjectIds: Guid[] (1..1000, после клиентской дедупликации) } + JWT либо X-Anonymous-Id. Заметок в localStorage нет — импортируются только лайки (Note = null).
  3. Система определяет владельца (JWT приоритетнее заголовка); если нет ни JWT, ни заголовка → ValidationError.
  4. Проверка входа: propertyObjectIds непуст и ≤ 1000 элементов (страховочный лимит запроса — localStorage реального устройства содержит десятки записей), каждый != Guid.Empty (иначе ValidationError); дубли внутри списка схлопываются серверной дедупликацией. Существование объектов недвижимости не проверяется (INV-FAV-004, ADR-0056).
  5. Система одной батч-выборкой находит уже существующие пары владельца среди propertyObjectIds (по partial-unique индексу владельца ux_favorites_user_property_object / ux_favorites_anonymous_property_object).
  6. Множество к созданию = propertyObjectIds − существующие. Для каждого нового создаётся запись избранного текущего владельца (UserId из JWT либо AnonymousId из заголовка — INV-FAV-001, Note = null); существующие записи не изменяются (импорт не несёт заметок). Каждая созданная запись поднимает доменный факт FavoriteAddedEvent.
  7. Гонка с параллельным одиночным add невозможна: уникальность пар гарантируют partial-unique индексы владельца (поток A4).
  8. На каждую созданную запись публикуется интеграционное событие FavoriteAddedKafkaEvent (ContractVersion = 1) в топик pin.favorites.favorite-added.v1; для пропущенных дублей события не публикуются. Потребитель user-activity получает единообразный поток add-событий (отдельного контракта «import» нет — семантика для истории действий та же).
  9. Все создания применяются в одном согласованном изменении — частичный импорт невозможен.
  10. Возвращается ImportFavoritesResult: ReceivedCount (после дедупликации), ImportedCount (создано), SkippedCount (= ReceivedCount - ImportedCount), TotalCount (общее число записей владельца после импорта).
  11. Интерфейс при успехе (2xx) удаляет ключ pin:favorites из localStorage — повторный импорт с этого устройства не запускается; обновляет список.
  12. Если пользователь при этом только что авторизовался и у него есть гостевая сессия с записями — далее обычный поток слияния: импорт выполняется до merge-preview (../domain/merge-strategy.md «Триггер», п.5; UC-FAV-002).

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

A1. pin:favorites пуст или отсутствует

  1. Интерфейс не вызывает импорт вовсе (ключ при наличии удаляется локально). Завершается.

A2. Повторный импорт (retry после сетевой ошибки / ключ не успел очиститься)

  1. Все пары «владелец + объект недвижимости» уже существуют → ImportedCount = 0, SkippedCount = ReceivedCount, событий нет. Ответ — успех; интерфейс очищает ключ. Завершается (полная идемпотентность).

A3. Импорт гостем, авторизация позже

  1. Импорт записал объекты недвижимости в гостевую сессию (AnonymousId); после входа они участвуют в слиянии как обычные S-записи — UC-FAV-002. Данные устройства не теряются независимо от порядка «импорт → вход». Завершается.

A4. Гонка импорта и одиночного add (пользователь кликает «сердечко» во время импорта)

  1. Одиночный add вставил пару раньше батча → создание импорта наталкивается на partial-unique индекс.
  2. Система повторяет шаги 5–6 (перечитывает существующие, сокращает набор к созданию) — до 2 повторов; итог без дублей. Завершается.

A5. Больше 1000 элементов в запросе

  1. ValidationError (400). Интерфейс шлёт чанками по 1000 (практически недостижимо: localStorage содержит десятки объектов недвижимости). Завершается.

A6. Бэкенд недоступен

  1. Импорт не удался — интерфейс не очищает pin:favorites и повторяет при следующей загрузке (идемпотентность A2 делает повтор безопасным). Локальный список продолжает отображаться из localStorage до успешной миграции. Завершается.

Постусловие

  • Все объекты недвижимости из pin:favorites устройства присутствуют в избранном текущего владельца ровно по одному разу (Note = null у созданных импортом); ранее существовавшие записи и их заметки не изменены.
  • pin:favorites удалён из localStorage — устройство переведено на бэкенд-избранное.
  • Опубликовано ImportedCount событий pin.favorites.favorite-added.v1 (по одному на созданную запись, ключ партиционирования FavoriteId, без гарантии межзаписного порядка); user-activity дописал ActionEvent-ы в историю владельца асинхронно (< 1 мин, at-least-once, дедупликация по MessageId). Фронт очищает pin:favorites уже после ответа 2xx: доставка событий гарантирована фиксацией записей и не зависит от готовности user-activity (недоступность шины импорт не откатывает).

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

КодHTTPУсловиеПоведение клиента
ValidationError400propertyObjectIds пуст / > 1000 / содержит Guid.Empty; нет ни JWT, ни X-Anonymous-IdОтфильтровать вход, чанковать, восстановить сессию
5xx500/503Хранилище/инфраструктура недоступны (A6)Не очищать localStorage; повторить при следующей загрузке

Частичный успех невозможен (одно согласованное изменение, шаг 9): ответ либо полный успех, либо ошибка без изменений.

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

СобытиеКогда поднимаетсяSubscribers
FavoriteAddedEventНа каждую созданную импортом записьВнутренние (метрики; построение Kafka-событий)

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

СобытиеTopicКогда публикуетсяConsumers
FavoriteAddedKafkaEvent (ContractVersion = 1, AggregateId = FavoriteId)pin.favorites.favorite-added.v1По одному на созданную запись; для пропущенных дублей — нетuser-activity (actions)

Отдельного события «импорт выполнен» нет: потребителям достаточно единообразных add-фактов (simplicity-first, ADR-0056).

Acceptance Criteria

#КритерийПроверка
AC1Импорт N новых объектов недвижимости создаёт N записей владельца с Note = null и N событийинтеграционный тест + outbox
AC2Повторный импорт того же списка — ImportedCount = 0, событий нет (A2)тест идемпотентности
AC3Смешанный список (часть уже в избранном) — создаются только отсутствующие; существующие заметки не затронутыинтеграционный тест
AC4Дубли внутри propertyObjectIds схлопываются (серверная дедупликация)unit-тест шага 4
AC5Гость импортирует в сессию; последующее слияние переносит записи в аккаунт (A3 → UC-FAV-002)сквозной тест «импорт → вход → merge»
AC6Сбой транзакции не оставляет частичного импорта (шаг 9)fault-injection тест
AC7Гонка импорта и одиночного add не создаёт дубль (A4)конкурентный тест
AC8Интерфейс очищает pin:favorites только после 2xx (A6)e2e-тест фронта

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

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

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

  • API метод: ../api/public/import-favorites.md.
  • Domain entity: ../domain/favorite.md (INV-FAV-001/002/004/006).
  • Следующий шаг после входа: UC-FAV-002, ../domain/merge-strategy.md (порядок «import до merge-preview»).
  • Границы: ../README.md.
  • Архитектурные решения: ADR-0052 (tenant), ADR-0056 (простота нагрузки).