POST /api/v1/favorites/import — Bulk-импорт избранного с устройства
Массово переносит список объектов недвижимости, накопленных клиентом локально на устройстве, в хранилище
избранного: клиент отправляет батч propertyObjectId[], сервис создаёт записи Favorite для текущего
владельца (гостевая сессия либо аккаунт, если пользователь уже авторизован), пропуская дубли
(INV-FAV-002). После успешного ответа клиент очищает локальный набор. Повторный вызов безопасен — все
записи окажутся дублями.
Назначение
Клиент может накопить список избранных объектов недвижимости локально на устройстве (например, гость до
первого обращения к бэкенду) и один раз перенести его в хранилище, чтобы дальше работали кросс-девайс
сценарии и слияние при авторизации (../../domain/merge-strategy.md,
раздел «Триггер и точки входа», п.5: импорт выполняется до merge-preview). Заметки при импорте не
передаются — переносятся только PropertyObjectId.
| Поле | Значение |
|---|
| Сервис/контекст | favorites |
| API area | public |
| Feature group | favorites-import |
| Статус | draft |
| Документ | docs/06-services/favorites/api/public/import-favorites.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Anonymous (гость по X-Anonymous-Id) или User (JWT) |
| Auth policy / Permissions | none (self-scope): записи создаются только владельцу из контекста запроса |
| Scope (RBAC) | не применяется |
| Record-level | владелец новых записей — актор запроса; чужого владельца задать нельзя (INV-FAV-005, INV-FAV-006 — импорт не переносит чужие записи, только создаёт свои) |
| Tenant isolation | не применяется — сущности вне tenant (ADR-0052 п.2) |
| Audit | create (батч): CreatedAt/UpdatedAt; каждый созданный лайк — событие pin.favorites.favorite-added.v1 |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/favorites/import |
| Success status | 200 (в т.ч. когда всё оказалось дублями — идемпотентный повтор) |
| Idempotency header | не применяется — идемпотентность по бизнес-ключу «владелец + объект недвижимости» (дубли пропускаются) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
UserId | identity (JWT claim sub) | Guid? | Условно | заполнен при авторизованном запросе; приоритет над AnonymousId | владелец — только сам актор |
AnonymousId | header X-Anonymous-Id | Guid? | Условно | валидный Guid ≠ Guid.Empty; используется при отсутствии JWT | недоверенный вход |
PropertyObjectIds | body propertyObjectIds | IReadOnlyList<Guid> | Да | 1..500 элементов; каждый != Guid.Empty; дубликаты внутри батча схлопываются; существование объектов недвижимости не проверяется (INV-FAV-004) | недоверенный вход — мусорные id безвредны (отфильтрует выдача каталога) |
Лимит 500 — защита от abuse: реальный размер локального набора — десятки записей
(scale envelope ADR-0056). Больше 500 — ValidationError; клиент может разбить на несколько вызовов.
Модель ответа ImportFavoritesResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
RequestedCount | int | Да | computed: число уникальных объектов недвижимости в запросе | — | Уникальных объектов недвижимости в запросе. |
ImportedCount | int | Да | computed: число созданных записей | — | Фактически создано новых записей. |
SkippedDuplicateCount | int | Да | computed: RequestedCount - ImportedCount | — | Пропущено — объект недвижимости уже был в избранном владельца. |
TotalCount | int | Да | computed: записей владельца после импорта | — | Итоговый размер избранного (для бейджа фронта). |
Алгоритм
- Контекст и доступ. Разрешить владельца:
UserId из JWT, иначе гостевая сессия AnonymousId из
заголовка. Не разрешён ни тот, ни другой — отказ FAVORITES_OWNER_UNRESOLVED.
- Проверка входа.
PropertyObjectIds не пуст, Count <= 500, каждый элемент != Guid.Empty.
Существование объектов недвижимости не проверяется (INV-FAV-004, ADR-0056 — нет sync-вызова в чужой
контекст); статуса у Favorite нет.
- Что читаем. Вход нормализуется (дубликаты в батче схлопываются). По списку запрошенных объектов
недвижимости определяются уже существующие записи владельца — они исключаются из вставки. К созданию
остаётся разность «запрошенные минус существующие».
- Бизнес-правила и маппинг. Для каждого объекта недвижимости к созданию формируется запись
Favorite
владельца из контекста (INV-FAV-001; Note = null — заметок при импорте нет). Поля: PropertyObjectId
из батча, CreatedAt/UpdatedAt — момент импорта. Каждая запись поднимает доменное событие
FavoriteAddedEvent. Счётчики: RequestedCount — уникальных на входе, ImportedCount — созданных,
SkippedDuplicateCount = RequestedCount - ImportedCount.
- Что меняется. Новые записи добавляются одним батчем. Гонка с параллельным
POST /favorites той же
пары отбивается partial-unique ux_favorites_user_property_object /
ux_favorites_anonymous_property_object: конфликтная строка исключается, набор существующих
перечитывается, вставка остатка повторяется один раз, счётчики пересчитываются.
- Какие факты/события фиксируются. По одному
FavoriteAddedKafkaEvent (ContractVersion = 1, topic
pin.favorites.favorite-added.v1, AggregateId = FavoriteId, AggregateType = "Favorite"; payload:
FavoriteId, UserId?, AnonymousId?, PropertyObjectId, OccurredAt, без Note) на каждую
созданную запись. Пропущенные дубли событий не порождают. Отдельного события «импорт выполнен» нет —
потребителю (user-activity) важны атомарные факты add.
- Согласованность. Все новые записи и все их события фиксируются в одном согласованном изменении;
частичный импорт невозможен (либо весь остаток к созданию, либо откат и retry).
- Результат. Модель
ImportFavoritesResult: счётчики шага 4 + TotalCount (число записей владельца
после импорта). HTTP 200 OK. Контракт клиента: при 200 очистить локальный набор; при любой ошибке
— оставить и повторить позже (повтор безопасен).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | FavoriteAddedKafkaEvent / pin.favorites.favorite-added.v1 / key = FavoriteId — по одному на созданную запись (до 500 событий за вызов) |
| Проекции / поиск | none (ADR-0056) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
FAVORITES_OWNER_UNRESOLVED | 400 | Нет ни JWT, ни валидного X-Anonymous-Id | «Не удалось определить владельца избранного» | no retry (сначала получить anonymous_id) |
ValidationError | 400 | Пустой propertyObjectIds; > 500 элементов; Guid.Empty в списке | детали валидации | no retry (клиент чинит батч) |
InternalError | 500 | Отказ хранилища / исчерпаны retry гонки unique | generic message | retry — импорт идемпотентен, локальный набор не очищен |
Предусловия и постусловия
- Предусловие. Разрешён владелец (JWT или гость по
X-Anonymous-Id); батч PropertyObjectIds —
1..500 непустых Guid; выполняется до merge-preview
(../../domain/merge-strategy.md, «Триггер и точки входа», п.5).
- Постусловие. Для каждого запрошенного объекта недвижимости у владельца есть запись
Favorite
(Note = null); созданные записи опубликованы по одному pin.favorites.favorite-added.v1 на запись;
повтор того же батча ничего не создаёт (все — дубли) — импорт идемпотентен по паре «владелец + объект».
Acceptance-критерии
- Given владелец без записей и батч из
K уникальных объектов недвижимости, When POST /import,
Then создаётся K записей, ImportedCount = K, SkippedDuplicateCount = 0, публикуется K событий
favorite-added, ответ 200.
- Given часть объектов батча уже в избранном владельца, When
POST /import, Then создаются
только новые записи, дубли пропущены (SkippedDuplicateCount), события — только на созданные.
- Given батч содержит внутренние дубликаты, When
POST /import, Then дубликаты схлопываются,
RequestedCount считается по уникальным объектам недвижимости.
- Given тот же батч отправлен повторно, When
POST /import, Then ImportedCount = 0, всё —
дубли, события не публикуются, ответ 200 (клиент вправе очистить локальный набор).
- Given батч пустой,
> 500 элементов или содержит Guid.Empty, When POST /import, Then
отказ ValidationError (400).
- Given запрос без JWT и без валидного
X-Anonymous-Id, When POST /import, Then отказ
FAVORITES_OWNER_UNRESOLVED (400).
- Given параллельный
POST /favorites той же пары во время импорта, When гонка partial-unique,
Then конфликтная строка исключается, вставка остатка повторяется один раз, счётчики пересчитываются,
дубль не создаётся.
Совместимость и наблюдаемость
- Будущее additive-расширение: элементы с заметками (
items: [{propertyObjectId, note}]) — новое optional поле
body рядом с propertyObjectIds, без breaking change.
- Logs: info
FavoritesImported {owner_type} {RequestedCount} {ImportedCount} — список PropertyObjectId не
логируется.
- Metrics:
favorites_import_total{owner_type}, favorites_import_batch_size (histogram),
favorites_import_duplicates_total.
- Traces: сквозной correlation; dashboard/alerts/runbook — ../../README.md.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы