UC-FAV-004. Первичная миграция избранного с устройства (localStorage → бэкенд)
Назначение
Описывает одноразовый bulk-импорт избранного с устройства: интерфейс может хранить лайки гостя в
localStorage браузера (ключ pin:favorites) до перехода на бэкенд-избранное. При первой загрузке
страницы /favorites он переносит содержимое pin:favorites в текущего владельца (гостевую сессию или
аккаунт) вызовом POST /favorites/import, после успеха очищает ключ и дальше работает только через API.
Импорт идемпотентен — существующие пары пропускаются. Связан с контекстом favorites.
| Поле | Значение |
|---|
| ID | UC-FAV-004 |
| Контекст | favorites (см. docs/06-services/favorites/) |
| Тип | основной (одноразовый на устройство) |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | Гость (X-Anonymous-Id) или Инвестор/Агент (JWT) — кто открыл /favorites первым |
| Permissions / scope | self-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 |
Основной Поток
- Интерфейс при инициализации обнаруживает непустой
pin:favorites, парсит его в массив
propertyObjectIds (невалидные значения — не-Guid — отбрасывает на клиенте).
- Интерфейс отправляет
POST /favorites/import с телом ImportFavoritesForm { propertyObjectIds: Guid[] (1..1000, после клиентской дедупликации) } + JWT либо X-Anonymous-Id. Заметок в localStorage нет —
импортируются только лайки (Note = null).
- Система определяет владельца (JWT приоритетнее заголовка); если нет ни JWT, ни заголовка →
ValidationError.
- Проверка входа:
propertyObjectIds непуст и ≤ 1000 элементов (страховочный лимит запроса — localStorage
реального устройства содержит десятки записей), каждый != Guid.Empty (иначе ValidationError); дубли
внутри списка схлопываются серверной дедупликацией. Существование объектов недвижимости не проверяется
(INV-FAV-004, ADR-0056).
- Система одной батч-выборкой находит уже существующие пары владельца среди
propertyObjectIds (по
partial-unique индексу владельца ux_favorites_user_property_object / ux_favorites_anonymous_property_object).
- Множество к созданию =
propertyObjectIds − существующие. Для каждого нового создаётся запись избранного
текущего владельца (UserId из JWT либо AnonymousId из заголовка — INV-FAV-001, Note = null);
существующие записи не изменяются (импорт не несёт заметок). Каждая созданная запись поднимает
доменный факт FavoriteAddedEvent.
- Гонка с параллельным одиночным add невозможна: уникальность пар гарантируют partial-unique индексы
владельца (поток A4).
- На каждую созданную запись публикуется интеграционное событие
FavoriteAddedKafkaEvent
(ContractVersion = 1) в топик pin.favorites.favorite-added.v1; для пропущенных дублей события не
публикуются. Потребитель user-activity получает единообразный поток add-событий (отдельного контракта
«import» нет — семантика для истории действий та же).
- Все создания применяются в одном согласованном изменении — частичный импорт невозможен.
- Возвращается
ImportFavoritesResult: ReceivedCount (после дедупликации), ImportedCount (создано),
SkippedCount (= ReceivedCount - ImportedCount), TotalCount (общее число записей владельца после импорта).
- Интерфейс при успехе (2xx) удаляет ключ
pin:favorites из localStorage — повторный импорт с этого
устройства не запускается; обновляет список.
- Если пользователь при этом только что авторизовался и у него есть гостевая сессия с записями —
далее обычный поток слияния: импорт выполняется до merge-preview
(../domain/merge-strategy.md «Триггер», п.5;
UC-FAV-002).
Альтернативные Потоки
A1. pin:favorites пуст или отсутствует
- Интерфейс не вызывает импорт вовсе (ключ при наличии удаляется локально). Завершается.
A2. Повторный импорт (retry после сетевой ошибки / ключ не успел очиститься)
- Все пары «владелец + объект недвижимости» уже существуют →
ImportedCount = 0, SkippedCount = ReceivedCount,
событий нет. Ответ — успех; интерфейс очищает ключ. Завершается (полная идемпотентность).
A3. Импорт гостем, авторизация позже
- Импорт записал объекты недвижимости в гостевую сессию (
AnonymousId); после входа они участвуют в слиянии как
обычные S-записи — UC-FAV-002. Данные устройства не
теряются независимо от порядка «импорт → вход». Завершается.
A4. Гонка импорта и одиночного add (пользователь кликает «сердечко» во время импорта)
- Одиночный add вставил пару раньше батча → создание импорта наталкивается на partial-unique индекс.
- Система повторяет шаги 5–6 (перечитывает существующие, сокращает набор к созданию) — до 2 повторов;
итог без дублей. Завершается.
A5. Больше 1000 элементов в запросе
ValidationError (400). Интерфейс шлёт чанками по 1000 (практически недостижимо: localStorage
содержит десятки объектов недвижимости). Завершается.
A6. Бэкенд недоступен
- Импорт не удался — интерфейс не очищает
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 | Условие | Поведение клиента |
|---|
ValidationError | 400 | propertyObjectIds пуст / > 1000 / содержит Guid.Empty; нет ни JWT, ни X-Anonymous-Id | Отфильтровать вход, чанковать, восстановить сессию |
5xx | 500/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-тест фронта |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы